Load Fast.
Don't Suck.

Some Honest Advice For Core Web Vitals

(a talk by @AhoyLemon)

Load Fast.
Don't Suck.

Some Honest Advice For Core Web Vitals

(a talk by @AhoyLemon)

Load Fast.
Don't Suck.

Some Honest Advice For Core Web Vitals

(a talk by @AhoyLemon)

Load Fast.
Don't Suck.

Some Honest Advice For Core Web Vitals

(a talk by @AhoyLemon)

Load Fast.
Don't Suck.

Some Honest Advice For Core Web Vitals

(a talk by @AhoyLemon)

Load Fast.
Don't Suck.

Some Honest Advice For Core Web Vitals

(a talk by @AhoyLemon)

Load Fast.
Don't Suck.

Some Honest Advice For Core Web Vitals

(a talk by @AhoyLemon)

Load Fast.
Don't Suck.

Some Honest Advice For Core Web Vitals

(a talk by @AhoyLemon)

Load Fast.
Don't Suck.

Some Honest Advice For Core Web Vitals

(a talk by @AhoyLemon)

Load Fast.
Don't Suck.

Some Honest Advice For Core Web Vitals

(a talk by @AhoyLemon)

Load Fast.
Don't Suck.

Some Honest Advice For Core Web Vitals

(a talk by @AhoyLemon)

Load Fast.
Don't Suck.

Some Honest Advice For Core Web Vitals

(a talk by @AhoyLemon)

Hi!

My name is Lemon.

(I make websites.)

“Your new favorite AI engineering partner”

  • development agency
  • Minneapolis, MN
  • AI-first
  • Anthropic partner
I am a serious professional.
I am a serious professional.

The Psychology of Performance

6 Thresholds

seconds
< 0.1 Instant
0.1 - 0.3 Machine
0.3 - 1 Flow
1 - 3 Anxiety
3 - 5 Abandonment
10+ Ghost Town
0 - 100 msInstant
  • Action and reaction feel fused
  • Interface feels like a physical extension of the hand
  • The ceiling we're chasing
Nielsen Norman Group — "Response Times: The 3 Important Limits"
100ms - 300 msThe Machine
  • Slight perceptible lag, but still feels like you're in control
  • The computer is visibly doing something — but you're not waiting
  • Gold standard for snappy web interactions
Nielsen Norman Group — "Response Times: The 3 Important Limits"
300ms - 1sThe Flow Threshold
  • Limit for keeping a user's train of thought uninterrupted
  • At 1s, users lose the sense of operating directly on the data
Robert B. Miller — "Response Time in Man-Computer Conversational Transactions" (1968)
1s - 3sThe Anxiety Threshold
  • Mental focus shifts
  • User is aware of delay
  • Bounce probability increases by 32%
Google / SOASTA — "New Industry Benchmarks for Mobile Page Speed" (2017)
3s - 5sThe Abandonment Threshold
  • User's attention is fully on the delay
  • User is actively deciding whether to stay or go
  • Bounce probability increases by 90%
Google / SOASTA — "New Industry Benchmarks for Mobile Page Speed" (2017)
10 secondsThe Ghost Town
  • Your site doesn't have users anymore
Nielsen Norman Group — "Response Times: The 3 Important Limits"

CrUX

Chrome User Experience Report

  • Launched 2017 by the Chrome team
  • Real field data from opted-in Chrome users
  • Rolling 28-day window — millions of real sessions

Google made it a ranking factor.

In June 2021, Google launched the Page Experience update.

Core Web Vitals — drawn from CrUX — became an official search ranking signal.

Your CrUX score is your position in search results.

It's not just Google.

Google Search Console
PageSpeed Insights
Cloudflare Observatory
Ahrefs
SEMrush
Moz
Shopify
Wix
Treo
DebugBear
SpeedCurve

5 metrics

TTFBTime to
First Byte
FCPFirst
Contentful Paint
LCPLargest
Contentful Paint
INPInteraction
to Next Paint
CLSCumulative
Layout Shift

CrUX report: NDC Oslo.com

For each metric, we'll answer two questions.

  • What is it?
  • Why should I care?

TTFB

Time To First Byte

The user requested your webpage and is waiting for a response from the server.

This is measured in milliseconds (ms).

< 300ms Excellent This feels instant to users.
< 800ms Good Google's passing grade for a "healthy" user experience.
800ms - 1800ms Needs Improvement Users will notice a blank white screen. Your server is struggling.
1800ms+ Failure We can assume the page is dead.
Q:
Why should I care?
A:

Because you want your site to have users. Higher TTFB means users will bounce.

But first, we need a baseline.

Which means we need another acronym.

RTT

Round Trip Time

The time it takes for the client to send a signal to the server and receive an acknowledgment back.

This is also measured in milliseconds (ms).

aka...

ping

ping

Step What happens? Cost
TCP

Hello server, are you there?

1 RTT
TLS 1.3

Yes! Here is our encryption key.

1 RTT
HTTP/2

Great! Show me what you got.

0.5 RTT
TOTAL 2.5 RTTs
Connection Type Typical RTT Handshake (×2.5)
Fiber 10ms 25ms
Cable 25ms 63ms
DSL 50ms 125ms
5G 45ms 113ms
4G / LTE 70ms 175ms
3G 200ms 450ms
Excellent < 300ms
Good < 800ms
Needs Improvement < 1800ms

Bad Things...

getAvatar.php
<?php
  if (isset($_COOKIE['sid'])) {
      if ($email = json_decode(file_get_contents("api/email?sid=".$_COOKIE['sid']), true)['email']) {
          if ($status = json_decode(file_get_contents("api/status?email=".$email), true)['active']) {
              if ($pref = json_decode(file_get_contents("api/pref?email=".$email), true)) {
                  if ($theme = $pref['settings']['theme']) {
                      if ($avatar = json_decode(file_get_contents("api/avatar?id=".$pref['id']), true)['url']) {
                          define('READY', true);
                          echo "<img src='$avatar' class='$theme'>";
                      }
                  }
              }
          }
      }
  } else {
      header("Location: /login");
  }
GetUserTheme.sql
SELECT
    * FROM users
GetUserTheme.sql
SELECT
    * FROM users
LEFT JOIN
    orders ON users.id = orders.user_id
GetUserTheme.sql
SELECT
    * FROM users
LEFT JOIN
    orders ON users.id = orders.user_id
LEFT JOIN
    activity_logs ON users.id = activity_logs.user_id
GetUserTheme.sql
SELECT
    * FROM users
LEFT JOIN
    orders ON users.id = orders.user_id
LEFT JOIN
    activity_logs ON users.id = activity_logs.user_id
LEFT JOIN
    site_themes ON users.theme_id = site_themes.id
GetUserTheme.sql
SELECT
    * FROM users
LEFT JOIN
    orders ON users.id = orders.user_id
LEFT JOIN
    activity_logs ON users.id = activity_logs.user_id
LEFT JOIN
    site_themes ON users.theme_id = site_themes.id
WHERE
    users.id = 42
LIMIT 1

Bad Hosting

Good Things...

.htaccess
# Cache static assets for 1 year

  Header set Cache-Control "public, max-age=31536000, immutable"


# Cache HTML for 24 hours (CDN) / 1 hour (browser)

  Header set Cache-Control "public, max-age=3600, s-maxage=86400"


# Don't cache dynamic content

  Header set Cache-Control "no-cache, must-revalidate"

HTTP/3 + TLS 1.3

Handshake Cost 0-RTT Resume?
HTTP/1.1 + TLS 1.2 3+ RTTs No
HTTP/2 + TLS 1.3 2.5 RTTs No
HTTP/3 + TLS 1.3 1 RTT Yes

The handshake is done.

The First 14kb

  1. Handshake
    TCP/TLS Negotiation (2.5 RTTs)
  2. Initial Burst
    The first 14KB is sent immediately.
  3. ACK Wait
    Server stops. Waits for confirmation.
  4. Everything Else
    The rest follows.

initcwnd

Initial Congestion Window

10 segments × 1,460 bytes = ~14.6KB

Oh no! That means we have to discuss

"The Fold"

And now, a brief tangent about...

"The Fold"

The Business View

"Above the fold" is where everything important should go.

This was true of newspapers in the 1950's.

And everything on our website is important.

Therefore everything must go above the fold.

And therefore...

CAROUSEL

CAROUSEL

The

CAROUSEL

is where content goes to die.

  • 1% of users click a carousel feature.
  • Of those, 90% only click the first one.
  • Slower sites. Frustrated users. Fewer conversions.
  • Ad blindness takes effect here.
  • Bad for accessibility, SEO, and performance.

but

Requirements
are requirements

FCP

First Contentful Paint

The first pixels of your website's content appear on the user's screen.

< 1.8s Good Users feel the page responding immediately.
1.8s - 3s Needs Improvement Noticeable blank screen. Users are starting to question the page.
3s+ Failure Most users have already mentally abandoned this page.
Q:
Why should I care?
A:
FCP sets the tone for the entire experience.

Bad Things...

index.html
<head>
  <link rel="stylesheet" href="/styles/global.css">
  <link rel="stylesheet" href="/styles/unused-bootstrap-v2.css">
  <link rel="stylesheet" href="/styles/that-one-widget-we-deleted-in-2019.css">

  <script src="/js/jquery-ui-full-1.12.1.min.js"></script>

  <script src="https://dead-tracking-service.io/tracker.js"></script>
</head>
page-with-icon.html
<head>

  <link rel="stylesheet" href="/css/fontawesome-all-pro-v6.css">

</head>
<body>

  <p>Check out my blog!</p>
  <i class="fa-solid fa-face-smile-wink"></i>

</body>
app.html
<head>

  <title>Enterprise SaaS Turbo Ultra</title>

</head>
<body>

  <div id="app"></div>

  <script src="/static/js/main.9f2a83b1.chunk.js"></script>
  <script src="/static/js/vendors~heavy-enterprise-modules.882c12.bundle.js"></script>

</body>
typography.css
@font-face {
  font-family: 'ExtremelyFancySerif';
  src: url('/fonts/ultra-bold-italic-extra-wide.woff2');
}

h1 {
  font-family: 'ExtremelyFancySerif';
}

FOIT & FOUT

Flash of Invisible Text
Flash of Unstyled Text

Well, let's see a demo...

Good Things...

index.html
<head>

  <script src="/analytics.js" async></script>
  <script src="/app-interactivity.js" defer></script>

</head>
<body>

  <header class="site-header">...</header>
  <div class="skeleton-nav"></div>
  <img src="/footer-ad.jpg" loading="lazy" width="300" height="250">

</body>
typography.css
@font-face {
  font-family: 'BrandFont';
  src: url('/fonts/brand-font.woff2') format('woff2');
  /* The browser paints instantly with a system font */
  font-display: swap;
}

body {
  /* Falls back nearest available fonts until 'BrandFont' is ready */
  font-family: 'BrandFont', 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
app.html
<head>

  <title>Enterprise SaaS Turbo Ultra</title>

</head>
<body>

  <div id="app">
    <header>...</header>
    <main>
      <h1>Welcome to the App</h1>
      <p>This content was rendered on the server!</p>
    </main>
    <footer>...</footer>
  </div>

  <script src="/static/js/main-bundle.js" defer></script>

</body>
layout.html
<head>
  <style>
    /* Critical CSS: above-fold styles inlined — no network request */
    .site-header { height: 60px; background: #1a1a1a; }
    .nav-link { color: #fff; padding: 10px; }
  </style>
</head>
<body>
  <header class="site-header">
    <nav class="nav-link">Home</nav>
  </header>

  <link rel="stylesheet" href="/styles.css">

  <main>...</main>

  <link rel="stylesheet" href="/print.css" media="print">
</body>

LCP

Largest Contentful Paint

The largest visible content element on your website has been rendered.

< 2.5s Good The main content feels like it arrived quickly.
2.5s - 4s Needs Improvement Users notice the wait. The page feels unfinished.
4s+ Failure "Is anything ever going to show up?"
Q:
Why should I care?
A:
LCP is the moment your page becomes useful.

Bad Things...

hero-hidden.css

1. The "Secret Agent" Image

/* The browser doesn't know this exists until the CSS is parsed */
.header-wrapper .hero-inner {
  background-image: url('/assets/huge-hero.jpg');
}
index.html

2. The "Last in Line"

<body>
  <img src="/hero.jpg" loading="lazy" alt="Hero">
</body>
app.html

3. The "Mystery Box"

<body>
  <img src="huge-hero.jpg">
</body>

Good Things...

index.html

1. The "VIP Pass"

<head>
  <link rel="preload" as="image" href="/hero.avif">
</head>
<body>
  <img src="/hero.avif" fetchpriority="high" alt="Hero">
</body>
layout.css

2. The "Pre-Reserved Seat"

.hero-image {
  /* Reserves a 16:9 box so the page doesn't jump */
  aspect-ratio: 16 / 9;
  width: 100%;
  height: auto;
}
responsive.html

3. The "Right-Sized Payload"

<picture> 
  <source srcset="hero-400.webp" media="(max-width: 400px)" type="image/webp">
  <source srcset="hero-600.webp" media="(max-width: 600px)" type="image/webp">
  <source srcset="hero-900.webp" media="(max-width: 900px)" type="image/webp">
  <source srcset="hero-1200.webp" media="(max-width: 1200px)" type="image/webp">
  <source srcset="hero-1800.webp" type="image/webp">
  <img src="hero-1800.webp" alt="Hero">
</picture>

INP

Interaction to Next Paint

Measures how long it takes for the page to visually respond to user input.

< 200ms Good Feels instant. The page is listening.
200ms - 500ms Needs Improvement User notices the lag. Trust starts to erode.
500ms+ Failure Clicking into the void.
Q:
Why should I care?
A:
A page that loads fast but responds slow still feels broken.

Bad Things...

checkout.js

The Greedy Handler

button.addEventListener('click', () => {

  const data = JSON.parse(massivePayload);  // ~300ms

  rows.forEach(row => {
    const h = row.offsetHeight;             // forces layout
    row.style.height = (h * 1.5) + 'px';   // invalidates layout
  });                                        // × 500 rows

  localStorage.setItem('cart', JSON.stringify(data)); // ~80ms

  // Total: ~600ms of main thread. Page looks frozen.
});

Good Things...

checkout.js

scheduler.yield()

button.addEventListener('click', async () => {

  const data = JSON.parse(massivePayload);

  await scheduler.yield(); // hand control back to the browser

  rows.forEach(row => {
    row.style.height = (row.offsetHeight * 1.5) + 'px';
  });

  await scheduler.yield(); // and again

  localStorage.setItem('cart', JSON.stringify(data));

});
search.js

Debounce the Expensive Stuff

// ✗ Runs on every keystroke
input.addEventListener('input', searchEverything);

// ✓ Waits until the user stops typing
input.addEventListener('input', debounce(searchEverything, 300));

CLS

Cumulative Layout Shift

Measures the visual stability of your page by tracking unexpected layout shifts.

< 0.1 Good Stable. Content stays where users expect it.
0.1 - 0.25 Needs Improvement Noticeable jumping. Users are losing their place.
0.25+ Failure The page is actively fighting the user.

Each layout shift is scored as:

impact fraction × distance fraction

Impact fraction
How much of the viewport was affected — where the element was plus where it went
Distance fraction
How far the element traveled, as a % of the viewport's largest dimension

A 400px element in an 800px viewport shifts down by 200px:

Impact fraction Before (rows 0–50%) + After (rows 25–75%)
= 75% of viewport = 0.75
Distance fraction Moved 200px ÷ 800px viewport
= 0.25
CLS Score 0.75 × 0.25 = 0.1875 — Needs Improvement
Q:
Why should I care?
A:

Your users are clicking the wrong things.

Bad Things...

index.html

The Unsized Image

<!-- Browser has no idea how tall this will be -->
<img src="/hero.jpg" alt="Hero">
<p>User is already reading this...</p>

<!-- Image arrives. Everything below shifts down. -->
page.html

The Late Arrival

<body>

  <main>
    <p>...to continue reading,</p>
    <a href="/next">Next page →</a>  <!-- 👆 user is here -->
  </main>

</body>
page.html

The Late Arrival

<body>

  <div class="ad-network-banner">
    <!-- SPONSORED -->               <!-- 👆 user clicks THIS -->
  </div>

  <main>
    <p>...to continue reading,</p>
    <a href="/next">Next page →</a>  <!-- ↓ now 90px lower  -->
  </main>

</body>
typography.css

The Font Swap Shove

@font-face {
  font-family: 'TallFont';
  src: url('/fonts/tall.woff2');
  /* font-display: swap = invisible text, then sudden reflow */
}

body { font-family: 'TallFont', sans-serif; }

/* Fallback font: line-height 1.4, 14 lines visible  */
/* TallFont arrives: line-height 1.8, now 11 lines   */
/* Everything below the text block just shifted up.  */

Good Things...

index.html

Explicit Dimensions

<!-- Tell the browser exactly what size to expect -->
<img src="/hero.jpg"
     width="1200"
     height="630"
     alt="Hero">

<!-- Or in CSS -->
<style>
  .hero { aspect-ratio: 16 / 9; width: 100%; }
</style>
layout.css

Reserve Space for Late Content

/* Give ad slots and widgets a guaranteed home */
.ad-banner    { min-height: 90px;  }
.cookie-bar   { min-height: 64px;  }
.chat-widget  { min-height: 56px;  }

/* Content loads into the reserved space — nothing shifts */
typography.css

font-display: optional

@font-face {
  font-family: 'BrandFont';
  src: url('/fonts/brand-font.woff2') format('woff2');
  font-display: optional;
}

/* optional: use the web font only if it's already cached.
   Otherwise, stick with the fallback. No swap. No shift. */

Takeaways...

Page Speed Insights

pagespeed.web.dev

Request Metrics Crux Report*

requestmetrics.com/resources/tools/crux

* not sponsored.**
**Hi Todd.

Google Search Console

search.google.com/search-console

Lighthouse

Lighthouse report in DevTools

Easy wins.

index.html

Size every image


Hero


Hero





…
layout.css

Reserve space for late arrivals

/* Anything injected after the initial render */
.ad-banner    { min-height: 90px;  }
.cookie-bar   { min-height: 64px;  }
.chat-widget  { min-height: 56px;  }

/* Content loads into space that was already there — nothing shifts */
typography.css

Two lines that kill font CLS

@font-face {
  font-family: 'BrandFont';
  src: url('/fonts/brand-font.woff2') format('woff2');
  font-display: optional; /* no swap, no shift */
}
handlers.js

Break up your handlers

// ✗ Everything fires before the browser can paint
input.addEventListener('input', searchEverything);
button.addEventListener('click', runTheWholeThing);

// ✓ Wait until the user pauses (INP win for inputs)
input.addEventListener('input', debounce(searchEverything, 300));

// ✓ Yield between chunks (INP win for heavy handlers)
button.addEventListener('click', async () => {
  doFirstThing();
  await scheduler.yield();
  doSecondThing();
});

This Talk

ahoylemon.github.io/load-fast
The plan
  1. Run PageSpeed Insights.
  2. Fix the worst score.
  3. Ship it.
  4. Repeat.

🤙🏻 hit me up

Training, mentoring, advice, etc
Mail.Ru lemon@ahoylemon.xyz
Bluesky @ahoylemon.xyz
Mastodon @ahoylemon@mastodon.social
Sessionize sessionize.com/lemon
GitHub github.com/AhoyLemon

🤙🏻 hit me up

Find me, I'm around.

Thank you.

ahoylemon.xyz