Skip to content
Software Atlas

Web Performance

What makes a page feel slow, and how to prove it: Core Web Vitals (LCP, INP, CLS), lab vs field measurement, images, fonts, code splitting, resource hints and caching. Measured in Chromium on a throttled phone profile, not guessed.

⏱ ~45 min read16 sections13 interactive widgetsHTML · CSS · JS · JSX
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

Your product page loads in under a second on your laptop. Then a colleague opens it on the bus: a blank screen, then text, then a photo that shoves the text down just as they tap "Add to cart", and the button seems to ignore them. Nothing is broken. The page is just slow in three different ways.

In one sentenceA page feels fast when it answers three questions well: does the main content show up quickly (LCP), does it react quickly when I tap (INP), and does it stay still while I use it (CLS)? Measure those three on real phones, then fix the biggest one first.

Together, these three are Google's Core Web Vitals. They're the most widely used definition of "fast" on the web, and Google Search uses them as one of its ranking signals. Watch one page load fail all three, then switch to the fixed version:

One page load, three questions
Illustrative timings on a phone. Watch what the visitor sees, then switch to the fixed version.
LCP: main content
—
not yet
CLS: jumps
—
not yet
INP: response
—
not yet
first paint
LCP
shift!
tap

The hero image has no size reserved and arrives late; a cookie banner is inserted above the button; the tap lands while the page is still busy running JavaScript.

1

LCP ≤ 2.5 s

The big image or headline is on screen within 2.5 seconds. Usually it's an image, and usually the fix is to request it earlier and make it smaller (§05, §06).
2

INP ≤ 200 ms

Every tap, click or key press shows a visible response within 200 ms. The fix is to keep the Main thread free: less JavaScript, in smaller pieces (§08, §09).
3

CLS ≤ 0.1

Content barely moves on its own. The fix is to reserve space for anything that arrives late: images, ads, banners, fonts (§07, §10).

Your laptop is not your users

The most common performance mistake isn't a bad line of code. It's testing only on a fast laptop with fast Wi-Fi. A mid-range phone runs JavaScript several times slower, and a mobile connection can take 100–200 ms just to send a message to a server and get the reply back. In this topic's measurements we use a "slow 4G phone" profile: 150 ms per round trip, 1.6 megabits per second, and a CPU slowed down 4×. That's the kind of device that decides your scores.

This topic builds on How Browsers Render: it explains frames, the main thread, and why a <script> can block a page. You don't need to read it first; we link to the exact section whenever we lean on it.

Foundations§02

Primer: from Enter to pixels

Every speed problem in this topic comes back to two costs. If you understand them, every fix that follows will feel obvious.

Cost 1: round trips

A round trip is one message going from the phone to a server and the reply coming back. Its duration is the round-trip time (RTT). On office Wi-Fi to a nearby server it can be 10–20 ms. On a phone with a weak mobile signal, 150 ms or more is normal.

Before the browser can even ask for your page, it needs several round trips just to set up a connection:

  1. DNS: look up the server's address from its name (often one round trip, sometimes cached).
  2. TCP: open a connection (one round trip).
  3. TLS: agree on encryption for HTTPS (one round trip with TLS 1.3).
  4. Send the request, wait for the server to build the page, and receive the first byte: one more round trip plus the server's own time. That moment is the time to first byte (TTFB).

Worked example. On the slow phone, 3 round trips × 150 ms = 450 ms just to connect. One more round trip plus 100 ms of server time puts the first byte of HTML at 700 ms. Nothing can appear on screen before that, no matter how small your page is. (DNS, TCP/IP and TLS explain each step in depth.)

Cost 2: bytes

Once the connection is open, bytes flow at the connection's Bandwidth. Network speeds are quoted in megabits per second, and file sizes in kilobytes. A byte is 8 bits, so divide by 8:

  • 1.6 Mbit/s ÷ 8 = 0.2 MB/s = 200 KB per second.
  • A 180 KB photo therefore needs about 0.9 s on its own, and longer when other files download at the same time and share the connection.

On fast Wi-Fi (50 Mbit/s ≈ 6,250 KB/s), the same photo takes about 30 ms. That's why a page can be instant on your laptop and slow on a phone: the phone pays far more for every round trip and every byte.

Then the browser discovers more files

The HTML is only the start. As it arrives, the browser's Preload scanner skims ahead for src and href URLs (stylesheets, scripts, images) and requests them right away. Some files are discovered later: a font is only requested once the CSS that mentions it has arrived and been applied, and it often lives on a different server, which means a brand-new connection.

Meanwhile, nothing is painted until the CSS has arrived, because stylesheets are Render-blocking (How Browsers Render §04 shows why). The first paint of any text or image is called First Contentful Paint (FCP).

Here's that whole sequence for a small page, as a waterfall: one row per file, time running left to right. Switch between the two networks:

What happens after you press Enter
Model of one page load (not a measurement): requests share the connection's bandwidth equally. 150 ms round trip, 1.6 Mbit/s.
page.html 30 KB
connect
style.css 20 KB
app.js 150 KB
hero.jpg 180 KB
fonts.example.com/brand.woff2 40 KB
connect
screen
fallback font
brand font
connect (DNS + TCP + TLS)waiting for the first bytedownloading
first byte (TTFB)
700 ms
first paint
1166 ms
brand font shows
2377 ms
LCP (hero image)
2817 ms

On the phone, 450 ms pass before the request is even sent (three round trips to set up the connection), and the three files found in the HTML split 1.6 Mbit/s between them. The hero image is the biggest file, so it finishes last.

Read it top to bottom. The HTML row starts with purple (connecting) and grey (waiting for the server). The three files the preload scanner found all start together once the HTML arrives. The font starts last, after a second connection to fonts.example.com. And the hero image, the biggest file, finishes last: on the phone that's around 2.8 s, which is already worse than the 2.5 s LCP target.

So every loading fix in this topic does one of three things:

1

Fewer round trips

Discover important files earlier, connect to fewer servers, cache what you can so repeat visits skip the network.
2

Fewer bytes

Smaller images, compressed text, less JavaScript, fonts with only the characters you need.
3

Better order

Fetch what's needed for the first screen first, and everything else later or not at all.
Foundations§03

Core Web Vitals: LCP, INP and CLS

In one sentenceEach Core Web Vital has a good and a poor threshold, and a page is judged by the visit at the 75th percentile: if 3 out of 4 visits are good, the page is good.
MetricQuestion it answersGoodNeeds improvementPoor
LCPWhen is the main content visible?≤ 2.5 s2.5–4 s> 4 s
INPHow fast does the page respond to input?≤ 200 ms200–500 ms> 500 ms
CLSHow much does the layout jump?≤ 0.10.1–0.25> 0.25

LCP: the biggest thing in view

As the page loads, the browser keeps track of the largest image or text block inside the visible area (the Viewport). Each time a bigger one is painted, it becomes the new LCP candidate. The last candidate before the user first taps, clicks, types or scrolls is the page's LCP.

  • What can be the LCP element: <img> elements, the poster image of a <video>, elements with a CSS background-image: url(…), and blocks of text such as a heading or paragraph.
  • What can't: invisible elements (for example opacity: 0) and images with almost no detail, such as a solid-colour placeholder.
  • In practice: on most pages the LCP element is an image (a hero image or product photo). On text-heavy pages it's a headline or the first paragraph.

INP: the slowest response

Every click, tap and key press is an interaction (scrolling and hovering are not). For each one, the browser measures the time from the input until the next Frame is painted. INP reports roughly the worst of those over the whole visit; on pages with many interactions, it skips one outlier for every 50 interactions, so a single freak hiccup doesn't decide it. §08 takes one interaction apart.

INP became a Core Web Vital in March 2024, replacing FID (First Input Delay), which only measured the waiting part of the first interaction. You'll still see FID in older articles and tools.

CLS: unexpected jumps

Each time visible content moves without the user asking for it, the browser scores that Layout shift by how much of the screen moved and how far. CLS adds up the shifts that happen close together and reports the worst such burst (§10 does the arithmetic). Movement within half a second after a tap or key press doesn't count, because the user caused it, and neither do transform animations (CSS that moves an element visually, like sliding it in, without changing the layout around it).

Why the 75th percentile?

You can't judge a page by one visit: the same page is fast on a laptop on fibre and slow on an old phone on a train. Google collects the metric from many real visits and takes the 75th Percentile: sort all the visits from fastest to slowest, and take the value three quarters of the way along. 75% of visits were at that value or better.

Why not the average? An average mixes fast and slow visits into one number, so it can look fine while a quarter of your visitors wait 4 s or more. It can't tell you how many people had a bad time; a percentile can. And why not the worst visit? Because one user on a broken connection would make every page "poor". The 75th percentile is the compromise. Mobile and desktop are judged separately, and your mobile number is almost always the worse one.

Twenty visits, one number
Each dot is one page load's LCP. Google judges a page by the 75th percentile: the value that 75% of visits are at or under.
p75 = 3.4 s
0 s2.5 s4 s6 s8 s+
1.11.31.41.61.71.92.02.12.32.42.62.82.93.13.43.64.24.85.57.9
75th percentile
3.4 s
needs improvement
average
2.9 s
hides the slow tail
slowest visit
7.9 s
one bad connection

Sorted, visit number 15 of 20 is the 75th percentile: 15 visits (75%) were at or under 3.4 s. The same page, twenty real visits: a few laptops, many mid-range phones, some on bad connections. The slow tail is long.

Practitioner§04

Measuring: lab vs field, PerformanceObserver and web-vitals

In one sentenceLab data is a test you control: repeatable, great for finding causes. Field data comes from real users: messy, but it's the truth. Use the lab to debug and the field to decide what to debug.
Lab dataField data
Where fromA test you run: Lighthouse, the DevTools Performance panel, WebPageTest, a CI jobReal visitors' browsers: CrUX, your own RUM
Device and networkWhatever you choose (use a throttled phone profile)Whatever your users have
Repeatable?Yes: change one thing, measure againNo: it moves with your audience, and takes days to show a change
Can measure INP?Only with a scripted interaction; a plain page-load test has no clicksYes
Good forDebugging, comparing two versions, catching regressions in CIKnowing whether users are actually fine; what Google Search sees

Lighthouse runs a page load without clicking anything, so it can't measure INP. Instead it reports Total Blocking Time (TBT): during the load, it adds up how far each Long task ran past 50 ms. A high TBT means the main thread is often busy, which is the main cause of bad INP. It's a hint, not the real thing.

Asking the browser directly

The browser measures all three vitals itself. You can listen with a PerformanceObserver: you tell it which entry type you care about, and it calls you back with each new entry.

watch LCP candidates and layout shifts
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // each entry is a new, bigger LCP candidate
    console.log("LCP candidate at", Math.round(entry.startTime), "ms:", entry.element);
  }
}).observe({ type: "largest-contentful-paint", buffered: true });

let cls = 0;
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) cls += entry.value;   // ← shifts right after user input don't count
  }
}).observe({ type: "layout-shift", buffered: true });

The widget below runs that exact code inside a tiny shop page, in a sandboxed iframe (a separate document, so it gets its own LCP and CLS). Load it with "no size" and "inserted late", then fix one thing at a time:

Measure a real page load (in your browser)
A tiny shop page loads inside a sandboxed frame. PerformanceObserver inside it reports LCP candidates and layout shifts, live.
hero image
cookie banner
hero arrives after
Pick the settings, then press Load the test page.
LCP (so far)
—
not yet
CLS (so far)
—
not yet
entries, in order
nothing yet

    Notice three things:

    1. LCP changes its mind. The first candidate is usually the paragraph of text, painted early. When the image arrives, it's bigger, so it replaces the text as the LCP element.
    2. An image without width and height causes a shift when it loads, because the browser didn't know how much space to keep for it. With the attributes, the space is reserved from the start and the image arriving moves nothing.
    3. The late banner shifts everything below it, unless its slot was reserved.

    In production: the web-vitals library

    Getting these numbers exactly right is harder than the snippet suggests: CLS windows, the INP outlier rule, pages restored from the back/forward cache, tabs opened in the background. Google's web-vitals library (a few KB compressed; the whole of version 6 is about 3 KB with Brotli) implements all of that and reports each metric the same way Chrome does.

    vitals.js — send real users' Core Web Vitals to your server
    import { onCLS, onINP, onLCP } from "web-vitals";
    
    function send(metric) {
      const body = JSON.stringify({
        name: metric.name,        // "LCP" | "INP" | "CLS"
        value: metric.value,      // ms for LCP and INP, a unitless score for CLS
        rating: metric.rating,    // "good" | "needs-improvement" | "poor"
        page: location.pathname,
      });
      navigator.sendBeacon("/analytics/vitals", body);  // ← still delivered if the tab is closing
    }
    
    onLCP(send);
    onINP(send);
    onCLS(send);

    Store each beacon with the page, device type and country, and compute the 75th percentile per page and device. Observability covers percentiles and dashboards; §13 covers what to collect so you can find causes, not just scores.

    Practitioner§05

    LCP: find the hero, then make it early and small

    In one sentenceThe LCP image has a journey: the HTML arrives, the image is discovered and requested, its bytes download, and it's painted. Every gap in that journey is LCP time. Make the request start early and the file small.

    LCP in four parts

    Where LCP time goes (image LCP)
    LCP
    1 · TTFB
    2 · load delay
    3 · load duration
    4 · render delay
    Illustrative proportions. Each part has its own fix; measure which one is biggest before changing anything.
    1. Time to first byte: until the HTML starts arriving. Fix on the server: caching, a CDN close to users, less work per request (Caching).
    2. Resource load delay: from the first byte of HTML until the browser requests the image. It's large when the image is discovered late. Target: close to zero.
    3. Resource load duration: downloading the image's bytes. Fix: fewer bytes (§06), and less competition from other downloads.
    4. Element render delay: the image has arrived but isn't painted yet, because a stylesheet or script is still blocking rendering, or because JavaScript hasn't created the element yet.

    In DevTools' Performance panel, the LCP marker on the timeline shows which element it was, and recent versions of Chrome show these four parts directly. In code, the element field of the largest-contentful-paint entry from §04 tells you.

    A measured experiment

    We built a small article page: a heading, a 1200 × 800 JPEG hero image (180 KB), 150 KB of JavaScript, a long article, and six 88 KB photos far below the fold (thousands of pixels down, so nobody sees them without scrolling a long way). Then we loaded variants of it in Chromium with a slow-4G phone profile and measured LCP. Click through the variants in order:

    Where does the hero image's time go?
    Measured on this machine (headless Chromium 153, Slow-4G emulation: 150 ms round trip, 1.6 Mbit/s, 4× CPU slowdown, phone-sized window). Median of 5 loads.
    page.html
    style.css
    app.js
    hero.jpg (180 KB)
    gallery 1–6 (88 KB each)
    LCP
    4.81 s
    012345 s

    Six 88 KB photos far below the fold download at the same time as the hero. They share the same 1.6 Mbit/s, so the hero needs 4.8 s. Nobody can even see those photos yet.

    Some requests start before the page.html bar ends: the preload scanner reads the HTML while it is still arriving and requests what it finds straight away.

    VariantHero request startsLCP (median of 5)
    six eager photos below the fold247 ms4.81 s
    …photos loading="lazy" (baseline)251 ms2.10 s
    hero with loading="lazy"566 ms2.21 s
    hero as a CSS background-image441 ms2.14 s
    hero inserted by JavaScript1,143 ms2.23 s
    JavaScript + <link rel="preload">192 ms2.06 s
    AVIF (87 KB) instead of JPEG257 ms1.36 s
    AVIF, 800 px wide (44 KB)258 ms0.88 s

    Measured on this machine: headless Chromium 153, HTTP/2, network emulation 150 ms round trip and 1.6 Mbit/s, 4× CPU slowdown, 412 × 915 window. Individual loads varied by up to about ±0.2 s.

    Three lessons, in order of size:

    • On a slow network, bytes are time. The two biggest wins removed bytes: not downloading invisible photos (−2.7 s), and a smaller file for the hero (−1.2 s). Every byte downloading at the same time as the hero competes with it.
    • Late discovery delays the request. A lazy hero waits for layout, a CSS background waits for the stylesheet (the Preload scanner only reads HTML), a JavaScript-inserted image waits for the script. In our test the LCP cost was modest (0.1 s), because the hero was sharing bandwidth anyway. With a bigger JavaScript bundle, a slower CPU, or the image on another server, the delay grows one-for-one.
    • preload rescues late discovery when you can't put the <img> in the HTML (for example, a single-page app that renders everything with JavaScript).

    The checklist for the LCP image

    product.html — the hero, done right
    <img
      src="/img/kayak-800.avif"
      srcset="/img/kayak-400.avif 400w, /img/kayak-800.avif 800w, /img/kayak-1200.avif 1200w"
      sizes="(min-width: 700px) 50vw, 100vw"
      width="1200" height="800"
      fetchpriority="high"
      alt="Two-seat kayak on a calm lake">
    • In the HTML, as a real <img>, so the preload scanner finds it in the first bytes.
    • Never loading="lazy" on it. Lazy loading is for images below the fold.
    • width and height so its space is reserved (CLS, §10).
    • Right-sized and in a modern format with srcset (§06).
    • fetchpriority="high", so the browser treats it as the most important image from the start.
    when JavaScript renders the hero (SPA): tell the browser early
    <head>
      <link rel="preload" as="image" href="/img/kayak-800.avif" fetchpriority="high">
    </head>

    When the LCP element is text

    If the largest element is a heading or paragraph, there's no image to download: LCP waits for the HTML, the render-blocking CSS, and possibly a Web font. The fixes are a fast TTFB, small critical CSS (the CSS needed for the first screen; How Browsers Render §04), and a font strategy that doesn't hide text (§07). If your page's content is built by JavaScript in the browser, LCP also waits for that JavaScript to download and run; Rendering Strategies compares sending ready-made HTML from the server instead.

    Practitioner§06

    Images: formats, srcset, lazy loading

    In one sentenceFor every image, ask three questions: is the format modern (AVIF or WebP for photos), is the file no bigger than the screen needs (srcset + sizes), and is it needed right now (if not, loading="lazy")?

    Images are usually the heaviest part of a page. In the HTTP Archive's yearly reports, images make up a large share of a typical page's bytes (often 40% or more). That makes them the easiest place to save.

    1. The format

    An image format decides how the pixels are compressed. We encoded the same test photo at five widths, with commonly used quality settings:

    WidthPNGJPEG (quality 80)WebP (quality 75)AVIF (quality 55)
    400 px—26 KB19 KB14 KB
    800 px—88 KB56 KB44 KB
    1200 px1,547 KB180 KB103 KB87 KB
    2400 px—639 KB311 KB297 KB

    Measured on this machine with Pillow 12 (libjpeg, libwebp, libavif) on a synthetic, detailed landscape test image. When we instead tuned each format to the same measured quality, the gap was similar: WebP 46% and AVIF 54% smaller than JPEG at 1200 px. Published comparisons on real photos usually find WebP about 25–35% smaller than JPEG and AVIF around 50% smaller, so expect less than our synthetic image shows, and measure your own.

    FormatUse it forNotes
    AVIFphotosSmallest files; slower to encode. Supported in all current major browsers.
    WebPphotos, images with transparencySupported everywhere current; a safe default.
    JPEGphotos, as the fallbackUniversal.
    PNGscreenshots, pixel art, when you need lossless (no detail thrown away)Huge for photos: 1.5 MB above, 9× the JPEG.
    SVGlogos, icons, simple illustrationsText-based vector drawing: sharp at any size, often tiny.

    To serve AVIF with a fallback for older browsers, use <picture>. The browser takes the first <source> whose type it supports and falls back to the <img>:

    <picture>
      <source type="image/avif" srcset="/img/lake-800.avif">
      <source type="image/webp" srcset="/img/lake-800.webp">
      <img src="/img/lake-800.jpg" width="1200" height="800" alt="A lake between green hills">
    </picture>

    In practice, a build step (for example the sharp library) or an image CDN that converts on the fly produces these files for you. You write the markup once.

    2. The size: srcset and sizes

    A phone shows a full-width image about 360–430 CSS pixels wide. But phones have dense screens: the device pixel ratio is usually 2 or 3, meaning each CSS pixel is 2×2 or 3×3 real pixels. So:

    Worked example. 400 CSS px × DPR 2 = 800 real pixels needed. From the table above, an 800-px JPEG is 88 KB. Without srcset, the same phone would download the 2400-px version made for big screens: 639 KB, over 7× more, for no visible difference.

    srcset and sizes let the browser choose:

    • srcset="lake-400.jpg 400w, lake-800.jpg 800w, …" lists the files and their real widths (the w means "this file is 400 pixels wide").
    • sizes="(min-width: 700px) 50vw, 100vw" says how wide the image will be shown: half the window (50vw) on screens at least 700 px wide, otherwise the full window width. The browser needs this because it picks the file before your CSS has been applied.

    The browser multiplies the shown width by the device pixel ratio and picks the smallest listed file that's big enough. Try it in your own browser:

    Which file does your browser download?
    A real <img> in a frame of the chosen width. Your browser picks the file; the frame reports img.currentSrc back.
    <img src="photo-800.jpg" alt="…"
         srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1200.jpg 1200w,
                 photo-1600.jpg 1600w, photo-2400.jpg 2400w"
         sizes="100vw">
    loading the frame…

    sizes="100vw" says "the image is as wide as the window". On the laptop that's wrong (CSS makes it half as wide above 700 px), so the browser picks a file twice as big as needed. Try the media-query version.

    3. The timing: lazy loading

    loading="lazy" tells the browser not to download an image until it's close to being scrolled into view. It's one attribute and saves every byte of every image the user never scrolls to.

    <img src="/img/review-photo.jpg" width="600" height="400" loading="lazy" alt="Customer photo">
    • Use it for images below the fold: galleries, long lists, comment avatars, footer logos. In §05 this alone cut LCP from 4.8 s to 2.1 s, because six invisible photos stopped competing with the hero.
    • Never on the LCP image or anything visible on the first screen: lazy images wait for layout before they're requested (§05 measured the delay).
    • "Close to view" is generous. Chromium starts loading lazy images well before they scroll into view, roughly 1,250–2,500 px away depending on the connection. That's why the §05 test page has a long article between the hero and the gallery: when we first tried it with the gallery only about 300 px below the first screen, the "lazy" photos were still requested straight away. Lazy loading helps most on long pages.

    <iframe loading="lazy"> works the same way, and is great for embedded videos and maps.

    4. Space: always set width and height

    Give every <img> its width and height attributes (the file's real pixel size is fine) and let CSS scale it:

    img { max-width: 100%; height: auto; }   /* scales down, keeps the shape */

    From the two numbers the browser works out the image's aspect ratio (its shape, here 3:2) and reserves the right amount of space before a single byte arrives. Without them, the image starts at zero height and pushes everything below it down when it loads: a layout shift (§10). For boxes without an image file (an embed, an ad slot), the CSS aspect-ratio: 16 / 9 property does the same job.

    Practitioner§07

    Fonts without the flash or the jump

    In one sentenceA web font arrives late, and when it replaces the Fallback font, the text changes size and the page jumps. Decide what shows while it loads (font-display), start the download earlier (preload), and make the fallback the same size (size-adjust).

    Why fonts are always late

    Look back at the waterfall in §02: the font was the last file to start. A browser only requests a font when both of these are true:

    1. The CSS containing the @font-face rule has downloaded and been parsed.
    2. Some text on the page actually uses that font. (The browser won't download fonts nobody needs, so it waits until it has matched styles to elements.)

    That puts the font request after the HTML and after the CSS: two steps behind. If the font lives on another server, add a fresh connection (three round trips, §02).

    What does the text do meanwhile?

    Browsers have two choices while the font downloads, and both have a name (FOIT and FOUT):

    • Hide the text (a Flash Of Invisible Text): the page has blank spaces where words should be.
    • Show it in a fallback font (a Flash Of Unstyled Text), then swap when the real font arrives.

    The CSS property font-display, inside the @font-face rule, picks the behaviour. The CSS spec defines it as two time windows: a block period (text is invisible) and a swap period (fallback text is shown and the real font may still replace it). Once the swap period is over, the page keeps the fallback, even if the font arrives later.

    What the reader sees while the font downloads
    A model of the font-display periods in the CSS spec. Drag the arrival time, then scrub through the first six seconds.
    fallback font
    your web font
    0 s123456 s
    A day at the lake
    Our trip started early. The hills were still in fog and the water was flat as glass.
    Newsletter box (moves when the text above changes height)

    swap: fallback text immediately (the spec allows a tiny block period; browsers keep it near zero), real font whenever it arrives. Good for brand headings; it guarantees a swap. Swap at 1.2 s: text re-flows, a layout shift unless the fallback font is metric-matched (see below).

    font-displayBlock period (invisible)Swap periodGood for
    blockabout 3 suntil it arrivesicon fonts, where a fallback letter would be nonsense
    swapnone (or close to it)until it arrivesbrand headings that must use the brand font
    fallbackabout 100 msabout 3 sbody text: short wait, then commit
    optionalabout 100 msnonebody text when the fallback is acceptable; zero shift
    auto (default)browser's choice, usually like blockdon't rely on it; set a value

    We measured the jump

    A test page with a heading and two paragraphs in a web font ("Brand", an 80 KB TrueType file), a newsletter box below them, and DejaVu Sans as the fallback. Same slow-4G profile as §05:

    SetupFont request starts → arrivesLayout shift (CLS)
    font-display: swap358 → 936 ms0.039
    font-display: block342 → 908 ms0.039 (!)
    font-display: fallback355 → 919 ms0.039
    font-display: optional341 → 908 ms0 (the font arrived too late, so this page view kept the fallback)
    swap + <link rel="preload">191 → 766 ms0.039
    swap + metric-matched fallback (size-adjust: 82%)336 → 904 ms0

    Measured on this machine (headless Chromium 153, Slow-4G emulation, 412 px wide window), median of 3; every run gave the same CLS.

    Read it row by row:

    • block shifted just as much as swap. The invisible text still takes up space, laid out with the fallback font's sizes. When the real font arrives, the lines re-wrap and the newsletter box moves. Hiding text doesn't prevent the jump; it only hides the words.
    • 0.039 from one font is 39% of the whole "good" CLS budget of 0.1, on a page with only two paragraphs.
    • Preload started the download about 170 ms earlier. The font still swapped in, just sooner.
    • Two setups removed the shift: optional (by never swapping late) and a fallback font scaled to match the web font's size (by making the swap move nothing).

    Fix 1: preload the one or two fonts the first screen needs

    in <head>, before the stylesheet
    <link rel="preload" href="/fonts/brand-latin.woff2" as="font" type="font/woff2" crossorigin>

    Preload only what the first screen uses: every preload competes with the LCP image for bandwidth (the waterfall in §12 shows the trade).

    Fix 2: a fallback that takes the same space

    The jump happens because the fallback's letters are a different width and height from the web font's. You can scale a local fallback font to match, with size-adjust and its friends:

    fonts.css
    @font-face {
      font-family: "Brand";
      src: url("/fonts/brand-latin.woff2") format("woff2");
      font-display: swap;
    }
    
    /* A local font, resized to take up the same space as Brand */
    @font-face {
      font-family: "Brand Fallback";
      src: local("Arial");
      size-adjust: 92%;          /* scale the letters */
      ascent-override: 95%;      /* space above the baseline */
      descent-override: 25%;     /* space below it */
    }
    
    body { font-family: "Brand", "Brand Fallback", sans-serif; }

    The numbers depend on the two fonts (the ones above are only examples; we found 82% for our pair by measuring). Tools such as Capsize or fontaine, and frameworks like Next.js's font module, calculate them from the font files for you.

    Fix 3: fewer, smaller font files

    • WOFF2 only. WOFF2 is compressed and supported by every current browser; skip .woff, .ttf and .eot.
    • Subset. A full font may contain thousands of characters for many languages. Keep only what you use, and split by script with unicode-range so the browser downloads, say, the Cyrillic file only if the page contains Cyrillic text.
    • Fewer weights. Each weight and style (regular, bold, italic) is usually its own file. A variable font holds a whole range of weights in one file; that's a win if you use three or more.
    • Self-host. Since 2020–2021, all major browsers keep a separate HTTP cache per site, so a font from a shared font service is not reused across sites any more. Hosting it yourself removes a connection to another server (and a third-party dependency). If you keep a font service, preconnect to its file server (§12).
    Practitioner§08

    INP: from tap to next paint

    In one sentenceAn interaction's time has three parts: waiting for the main thread (input delay), running your handlers (processing time), and drawing the result (presentation delay). Find out which part is big before you fix anything.

    Back in the shop: you ask the clerk a question. First you wait while they finish a phone call (input delay). Then they look up the answer (processing). Then they turn around and tell you (presentation). You only care about the total, but each part has a different fix.

    How Browsers Render §08 showed the rule behind all of this: the Main thread runs one task at a time, and the screen is only redrawn between tasks. Here's what that means for one tap:

    One tap, step by step
    Illustrative timings. Follow the click from finger-down to the frame that shows the result.
    Code
    1// already running: a 140 ms timer callback
    2setTimeout(sendAnalyticsBatch, 0);
    3
    4button.addEventListener("click", () => {
    5 const cart = addToCart(item); // 120 ms
    6 badge.textContent = cart.count; // DOM write
    7});
    Main threadone job at a time
    ⏱ sendAnalyticsBatch (timer) · 140 ms
    Screenwhat the user sees
    Add to cart · badge: 2
    This interactionINP parts
    empty
    A timer callback started 60 ms ago and needs 140 ms in total. The main thread is busy with it. Nothing about it is wrong; it's just ordinary work, like sending an analytics batch.
    1 / 8
    PartFrom → toTypical cause
    Input delayinput → your first handler startsother code is running: a timer, a third-party script, React Hydration, another handler
    Processing timefirst handler starts → last handler endsyour handlers: heavy logic, re-rendering a big React tree, synchronous storage or layout reads
    Presentation delayhandlers end → next frame on screenstyle, layout and paint for what you changed: a big DOM update, a huge page, forced layouts (layout thrashing)

    Now click a real one

    This button's handler is a plain DOM listener that does exactly what the toggles say. The numbers come from your browser's Event Timing API, the same data INP is built from:

    Click it, then read its INP parts (in your browser)
    Measured with PerformanceObserver({ type: "event" }). Durations are rounded by the browser to 8 ms.
    other code running
    click handler work
    DOM change
    Click a few times. Each row below is one real click.
    In your cart: 0
    last clicksinput delay processing presentation
    no clicks yet
    worst click so far
    —
    not yet

    Try each setting on its own and watch which coloured part grows. INP for a visit is roughly the worst click (or tap, or key press) of the whole visit.

    Try the three experiments one at a time:

    1. Idle, 10 ms, one line. Every part is small; the total is a few dozen milliseconds, well under 200. That's a good interaction.
    2. Background work on. Your handler didn't change, but now most clicks land while another 150 ms task is running, and the yellow input-delay part jumps. INP problems are often caused by code that has nothing to do with the thing the user clicked.
    3. 3,000 items. The handler is still fast, but the browser must style, lay out and paint 3,000 new elements before it can show the frame: the cyan presentation part grows. (Parsing the new HTML happens inside the handler, so the pink processing part grows a little too.)

    Reading Event Timing entries yourself

    log every slow interaction and its three parts
    new PerformanceObserver((list) => {
      for (const e of list.getEntries()) {
        if (!e.interactionId) continue;                         // not part of a click, tap or key press
        const inputDelay   = e.processingStart - e.startTime;
        const processing   = e.processingEnd - e.processingStart;
        const presentation = e.startTime + e.duration - e.processingEnd;
        console.log(e.name, Math.round(e.duration), "ms =", inputDelay, "+", processing, "+", presentation, e.target);
      }
    }).observe({ type: "event", durationThreshold: 40, buffered: true });
    Practitioner§09

    Fixing INP: paint first, yield, do less

    In one sentenceThe browser can't paint while your JavaScript runs. So do the visible part first, give the main thread back, then do the rest. Everything else is a variation on that idea, plus doing less work in total.

    Pattern 1: paint the feedback, then do the work

    INP stops counting at the next frame after your handlers finish. If the handler does a small visible change and then yields, that frame shows the change quickly, and the heavy work continues in a later task.

    ✕ All work before the next paint: INP includes the 120 ms
    button.addEventListener("click", () => {
      const cart = addToCart(item);      // 120 ms
      badge.textContent = cart.count;
      button.textContent = "Added ✓";
    });
    ✓ Feedback paints first: INP is just the cheap part
    button.addEventListener("click", async () => {
      button.textContent = "Adding…";    // cheap, visible
      await yieldToMain();               // ← usually lets the browser paint "Adding…"
      const cart = addToCart(item);      // 120 ms, in a new task
      badge.textContent = cart.count;
      button.textContent = "Added ✓";
    });
    yield.js
    export function yieldToMain() {
      // scheduler.yield() resumes your code before other queued tasks (Chromium 129+).
      if (globalThis.scheduler?.yield) return scheduler.yield();
      return new Promise((resolve) => setTimeout(resolve, 0));   // a new task everywhere else
    }

    The user still waits 120 ms for the final result, but they see a response at once, and INP measures that first frame.

    Be careful what you conclude, though: after the yield, addToCart is still one 120 ms long task. A second tap that arrives during it waits, exactly as before. To keep the page responsive throughout, split the work itself into chunks with a yield between them:

    the heavy part, in chunks
    // the click handler now does: const cart = await addToCart(item);
    async function addToCart(item) {
      const cart = loadCart();
      for (const step of [validate, reserveStock, updateTotals, saveDraft]) {  // ~30 ms each
        step(cart, item);
        await yieldToMain();                    // ← a gap after every chunk
      }
      return cart;
    }
    A second tap arrives 60 ms into the work
    (a) everything in the handler: the tap waits ~60 ms, and nothing is painted for 120 ms
    main
    handler 120 ms
    tap 2
    waiting
    (b) feedback, one yield, then one long task: "Adding…" shows fast, but the tap still waits
    main
    120 ms task
    tap 2
    waiting
    (c) feedback, then four 30 ms chunks with yields: the tap only waits for the current chunk
    main
    30
    30
    30
    30
    tap 2
    Illustrative. Yellow = cheap feedback, pink = heavy work, striped = the second tap waiting, cyan = its handler. Only splitting the work itself lets the second tap in quickly.

    Pattern 3's table ("One long loop") lists more ways to cut work into pieces.

    Pattern 2: in React, mark the slow update as a transition

    In React, a state update inside an event handler is urgent by default: React re-renders and commits before the browser paints. If the update re-renders a big list, every key press pays for the whole list. A transition tells React that part of the update can wait:

    Search.jsx
    import { memo, useState, useTransition } from "react";
    
    export function Search() {
      const [text, setText] = useState("");     // what's in the box
      const [query, setQuery] = useState("");   // what the list filters by
      const [isPending, startTransition] = useTransition();
    
      function onChange(e) {
        setText(e.target.value);                            // urgent: show the letter now
        startTransition(() => setQuery(e.target.value));    // can wait, can be interrupted
      }
    
      return (
        <>
          <input value={text} onChange={onChange} />
          {isPending && <span>updating…</span>}
          <ProductList query={query} />
        </>
      );
    }
    
    const ProductList = memo(function ProductList({ query }) { /* renders hundreds of rows */ });

    Feel the difference. Type "kayak" quickly in each mode:

    Type in the filter box (real React 19)
    250 products; each row is an artificially slow component (~0.8 ms) that highlights the matching text. Same list, two ways to schedule its update.
    list up to date
    slowest key press
    —
    type a few letters
    last key presses (ms, measured)
    —
    • kayak mini #1
    • paddle pro #2
    • tent classic #3
    • lantern lite #4
    • rope max #5
    • boots eco #6
    • jacket mini #7
    • stove pro #8
    • map classic #9
    • compass lite #10
    • bottle max #11
    • hammock eco #12
    • anchor mini #13
    • cooler pro #14
    • chair classic #15
    • towel lite #16
    • net max #17
    • hat eco #18
    • vest mini #19
    • pump pro #20
    • kayak classic #21
    • paddle lite #22
    • tent max #23
    • lantern eco #24
    • rope mini #25
    • boots pro #26
    • jacket classic #27
    • stove lite #28
    • map max #29
    • compass eco #30
    • bottle mini #31
    • hammock pro #32
    • anchor classic #33
    • cooler lite #34
    • chair max #35
    • towel eco #36
    • net mini #37
    • hat pro #38
    • vest classic #39
    • pump lite #40
    • kayak max #41
    • paddle eco #42
    • tent mini #43
    • lantern pro #44
    • rope classic #45
    • boots lite #46
    • jacket max #47
    • stove eco #48
    • map mini #49
    • compass pro #50
    • bottle classic #51
    • hammock lite #52
    • anchor max #53
    • cooler eco #54
    • chair mini #55
    • towel pro #56
    • net classic #57
    • hat lite #58
    • vest max #59
    • pump eco #60
    • kayak mini #61
    • paddle pro #62
    • tent classic #63
    • lantern lite #64
    • rope max #65
    • boots eco #66
    • jacket mini #67
    • stove pro #68
    • map classic #69
    • compass lite #70
    • bottle max #71
    • hammock eco #72
    • anchor mini #73
    • cooler pro #74
    • chair classic #75
    • towel lite #76
    • net max #77
    • hat eco #78
    • vest mini #79
    • pump pro #80
    • kayak classic #81
    • paddle lite #82
    • tent max #83
    • lantern eco #84
    • rope mini #85
    • boots pro #86
    • jacket classic #87
    • stove lite #88
    • map max #89
    • compass eco #90
    • bottle mini #91
    • hammock pro #92
    • anchor classic #93
    • cooler lite #94
    • chair max #95
    • towel eco #96
    • net mini #97
    • hat pro #98
    • vest classic #99
    • pump lite #100
    • kayak max #101
    • paddle eco #102
    • tent mini #103
    • lantern pro #104
    • rope classic #105
    • boots lite #106
    • jacket max #107
    • stove eco #108
    • map mini #109
    • compass pro #110
    • bottle classic #111
    • hammock lite #112
    • anchor max #113
    • cooler eco #114
    • chair mini #115
    • towel pro #116
    • net classic #117
    • hat lite #118
    • vest max #119
    • pump eco #120
    • kayak mini #121
    • paddle pro #122
    • tent classic #123
    • lantern lite #124
    • rope max #125
    • boots eco #126
    • jacket mini #127
    • stove pro #128
    • map classic #129
    • compass lite #130
    • bottle max #131
    • hammock eco #132
    • anchor mini #133
    • cooler pro #134
    • chair classic #135
    • towel lite #136
    • net max #137
    • hat eco #138
    • vest mini #139
    • pump pro #140
    • kayak classic #141
    • paddle lite #142
    • tent max #143
    • lantern eco #144
    • rope mini #145
    • boots pro #146
    • jacket classic #147
    • stove lite #148
    • map max #149
    • compass eco #150
    • bottle mini #151
    • hammock pro #152
    • anchor classic #153
    • cooler lite #154
    • chair max #155
    • towel eco #156
    • net mini #157
    • hat pro #158
    • vest classic #159
    • pump lite #160
    • kayak max #161
    • paddle eco #162
    • tent mini #163
    • lantern pro #164
    • rope classic #165
    • boots lite #166
    • jacket max #167
    • stove eco #168
    • map mini #169
    • compass pro #170
    • bottle classic #171
    • hammock lite #172
    • anchor max #173
    • cooler eco #174
    • chair mini #175
    • towel pro #176
    • net classic #177
    • hat lite #178
    • vest max #179
    • pump eco #180
    • kayak mini #181
    • paddle pro #182
    • tent classic #183
    • lantern lite #184
    • rope max #185
    • boots eco #186
    • jacket mini #187
    • stove pro #188
    • map classic #189
    • compass lite #190
    • bottle max #191
    • hammock eco #192
    • anchor mini #193
    • cooler pro #194
    • chair classic #195
    • towel lite #196
    • net max #197
    • hat eco #198
    • vest mini #199
    • pump pro #200
    • kayak classic #201
    • paddle lite #202
    • tent max #203
    • lantern eco #204
    • rope mini #205
    • boots pro #206
    • jacket classic #207
    • stove lite #208
    • map max #209
    • compass eco #210
    • bottle mini #211
    • hammock pro #212
    • anchor classic #213
    • cooler lite #214
    • chair max #215
    • towel eco #216
    • net mini #217
    • hat pro #218
    • vest classic #219
    • pump lite #220
    • kayak max #221
    • paddle eco #222
    • tent mini #223
    • lantern pro #224
    • rope classic #225
    • boots lite #226
    • jacket max #227
    • stove eco #228
    • map mini #229
    • compass pro #230
    • bottle classic #231
    • hammock lite #232
    • anchor max #233
    • cooler eco #234
    • chair mini #235
    • towel pro #236
    • net classic #237
    • hat lite #238
    • vest max #239
    • pump eco #240
    • kayak mini #241
    • paddle pro #242
    • tent classic #243
    • lantern lite #244
    • rope max #245
    • boots eco #246
    • jacket mini #247
    • stove pro #248
    • map classic #249
    • compass lite #250

    Each letter re-renders the whole list before the browser can paint the letter you typed. Type quickly and the box lags behind your fingers.

    Measured on this machine (production build, headless Chromium 153, typing "kayak" at one key per 120 ms): with plain setState each key press took 256–296 ms; with startTransition, 24–56 ms.

    The list does the same total work in both modes. The transition only changes when it runs: after the letter has been painted, in slices small enough that the next key press never waits long. useDeferredValue(query) is the same idea from the receiving side: the list gets a copy of query that is allowed to lag behind.

    Pattern 3: do less, or do it elsewhere

    ProblemFix
    A handler runs on every key press or pointer moveDebounce or throttle it (DOM Events §09)
    Pure computation (parsing, searching, image processing)Move it to a Web Worker: a separate thread that never blocks input
    Work that doesn't need to happen now (analytics, prefetching, logging)Run it after a yield, or in requestIdleCallback(fn), which runs fn when the browser has nothing else to do
    One long loopSplit it into chunks with a yield between them
    A big DOM update (presentation delay)Change less DOM: virtualize long lists, use content-visibility (How Browsers Render §11), avoid forced layouts (§07 there)
    React re-renders far more than what changedFind why (React's rendering model §06): split state, memoize, or let the React Compiler do it

    Pattern 4: shrink other people's tasks (input delay)

    Input delay comes from whatever else is running when the user taps. The usual suspects on a real site:

    • Third-party scripts: tag managers, A/B testing, chat widgets, ads. Each one runs on your main thread. Load them with async or defer, delay non-essential ones until after the page is interactive, and remove the ones nobody uses.
    • Hydration: when a server-rendered React page becomes interactive, React runs every component once, often as one long task. A tap during it waits. Rendering Strategies covers options that hydrate less, or in pieces.
    • Big JavaScript evaluation while the page loads: §11 shows how to ship less of it.
    Practitioner§10

    CLS: how shifts are scored, and how to stop them

    In one sentenceA Layout shift scores how much of the screen was affected × how far things moved. CLS adds up shifts that come in a burst and keeps the worst burst. The fix is nearly always the same: reserve the space before the content arrives.

    Back in the shop: you reach for a jar, and someone pushes the whole shelf down a row. You grab the wrong jar. On a page, that's tapping "Cancel" because "Buy" just moved under your finger. That's why CLS exists.

    Scoring one shift

    When something on screen moves to a new position between two frames, without the user asking for it, the browser computes two numbers:

    • Impact fraction: the area of the Impact region (everything the moving content covered, before or after the move; an area covered both times counts once) divided by the Viewport's area.
    • Distance fraction: how far it moved, divided by the viewport's longest side.

    Worked example. A phone viewport is 360 × 640 CSS pixels. A 300 px tall block of text starts at y = 140. A 120 px banner is inserted above it, so the block now spans y = 260 to 560. Together, the old and new positions cover y = 140 to 560: 420 px of height, the full width. Impact = 420 × 360 ÷ (360 × 640) = 0.656. Distance = 120 ÷ 640 = 0.1875. Score = 0.656 × 0.1875 = 0.123: already over the 0.1 "good" line, from one banner.

    Score one layout shift
    A banner is inserted above a block of content and pushes it down. CLS scores the jump from two numbers.
    impact fraction = area of (before ∪ after) ÷ viewport = 420 × 360 ÷ (360 × 640) = 0.656
    Old and new positions overlap, so the shared part counts once: 300 + 300 − 180 overlap = 420 px of height.
    distance fraction = move ÷ longest viewport side = 120 ÷ 640 = 0.188
    shift score = 0.656 × 0.188 = 0.123 (needs improvement on its own)
    block beforeblock afternew bannerimpact region (before ∪ after)

    Now drag the banner taller than the block (try 300 px banner, 100 px block). The old and new positions no longer touch, so the impact region is two separate boxes, 100 + 100 = 200 px tall, and the empty gap between them doesn't count: impact 0.3125, distance 0.469, score 0.146.

    Notice that the banner itself doesn't score. It's new, so it didn't move. What scores is the content it pushed.

    From shifts to CLS: session windows

    A page can shift many times over a long visit. Adding up every shift forever would punish pages people keep open for an hour, so CLS groups shifts into session windows:

    • A window starts with a shift and keeps collecting shifts that come less than 1 s after the previous one.
    • A window lasts at most 5 s.
    • CLS is the total of the worst window.
    Five shifts, three windows
    shifts
    windows
    0.09
    0.03
    0.13 ← worst
    Shifts at 0.4 s and 0.9 s fall in one window (0.05 + 0.04 = 0.09). The shift at 3 s starts a new window (0.03). Two shifts at 6.0 and 6.5 s form the worst window: 0.06 + 0.07 = 0.13. CLS = 0.13.

    What doesn't count

    • Shifts within 500 ms of a tap, click or key press. The user caused them, like opening an accordion. (Scrolling doesn't count as input here.) In code, these entries have hadRecentInput: true (§04).
    • transform animations. Moving an element with transform doesn't change layout, so nothing "shifts". Animate transform, never top or margin (How Browsers Render §06 explains the cost side too).
    • Content outside the viewport, and new content that doesn't push anything (appended at the bottom, or overlaid on top).

    The usual culprits, and their fixes

    CauseFix
    Images and videos without dimensionswidth and height attributes, CSS height: auto (§06)
    Ads, embeds, iframes that size themselves when they loadReserve the slot: min-height for the most common ad size, or aspect-ratio for videos and maps
    Banners and notices inserted at the top (cookies, "get the app", promotions)Overlay them (position: fixed at the bottom of the screen), or reserve their space in the initial HTML
    Web fonts swapping inMetric-matched fallback or font-display: optional (§07)
    Content loaded by JavaScript above what the user is reading ("related posts", "you may also like")Render a placeholder of the final size first (a "skeleton"), or put late content below the fold
    Animating height, top, marginAnimate transform instead
    reserve space before content arrives
    .ad-slot     { min-height: 250px; }                 /* the usual ad size for this slot */
    .video-embed { aspect-ratio: 16 / 9; width: 100%; } /* height follows from the width */
    .cookie-bar  { position: fixed; inset: auto 0 0 0; } /* floats over the page, pushes nothing */
    Practitioner§11

    Ship less JavaScript: code splitting and budgets

    In one sentenceAn image costs only its download. JavaScript costs its download, then main-thread time to parse, compile and run it, which delays everything else, including the user's first tap. Send each page only the code it needs now, and set a budget so it stays that way.

    Why JavaScript is the expensive byte

    Compare 150 KB of image with 150 KB of compressed JavaScript. The image is downloaded and decoded, mostly off the main thread, and then it's done. The JavaScript is downloaded, then decompressed to several times its size, parsed, compiled and executed on the Main thread. While that happens, the page can't respond: input delay (§08). On a mid-range phone this costs several times longer than on your laptop.

    That's why most sites' INP and LCP problems end in the same place: too much JavaScript, too early.

    Code splitting with import()

    By default a Bundler follows every import and packs everything into one file, the bundle. Code splitting breaks it up. The tool is the dynamic import():

    load the emoji picker only when it's needed
    emojiButton.addEventListener("click", async () => {
      const { openPicker } = await import("./emoji-picker.js");   // ← downloaded on first click
      openPicker(textarea);
    });

    In React: React.lazy and Suspense

    App.jsx
    import { lazy, Suspense, useState } from "react";
    
    const EmojiPicker = lazy(() => import("./EmojiPicker.jsx"));   // its own chunk
    
    export function Composer() {
      const [open, setOpen] = useState(false);
      return (
        <>
          <textarea />
          <button onClick={() => setOpen(true)}>😀</button>
          {open && (
            <Suspense fallback={<p>Loading…</p>}>
              <EmojiPicker />
            </Suspense>
          )}
        </>
      );
    }

    This widget is built exactly like that. The report panel's code is a separate file in this site's source; open it and the widget reports what actually happened on the network:

    Open a lazily loaded panel (real React.lazy)
    The panel's code lives in its own file, lazy-report.jsx. It isn't downloaded until the first time you open it.
    Press “Open report”.

    Close it and open it again: the second time there's no download and no fallback, because the module is already loaded.

    Measured: what splitting saved

    We built a small React 19 app twice with Vite 8: a text box with an emoji-picker button. The picker includes its data, 1,670 emoji with names. Once with a normal import, once with React.lazy:

    BuildMain JavaScript (gzip)Loaded laterReady to use on the slow phone
    picker imported normally93.6 KB—1,049 ms
    picker with React.lazy69.4 KB24.7 KB, on first click892 ms

    Sizes as printed by vite build. "Ready" is the time until the text box existed, in headless Chromium 153 with Slow-4G emulation and 4× CPU slowdown, median of 5.

    A quarter of the main bundle moved out, and the page was usable about 160 ms sooner, for a feature many visitors never open. The remaining 69 KB is almost all React and React DOM themselves.

    Where to split

    • Routes. Each page of a Single-page app only needs its own code. Most routers support lazy routes; this is the biggest, easiest win.
    • Things behind a click. Modals, editors, emoji pickers, charts, date pickers, admin panels.
    • Heavy libraries used in one place. A PDF renderer, a syntax highlighter, a map.
    • Not tiny components. Every chunk is another request with its own round trip; splitting a 2 KB button out costs more than it saves.

    Hide the delay by starting the download when the user shows intent, before they click:

    const loadPicker = () => import("./EmojiPicker.jsx");
    const EmojiPicker = lazy(loadPicker);
    
    <button onPointerEnter={loadPicker} onFocus={loadPicker} onClick={() => setOpen(true)}>😀</button>

    Hovering or focusing the button starts the download; by the time the click lands, the chunk is often already there. Calling loadPicker several times is fine, because the browser loads a module once.

    Keep it small: compression and budgets

    Text files must be compressed on the way out. gzip or Brotli are applied by your server or CDN, announced with the Content-Encoding header. Our eager bundle above: 319 KB raw, 93.6 KB with gzip (as vite build reports it), 78 KB with Brotli at its highest setting, 16% smaller than gzip.

    Bundles grow one innocent npm install at a time, so make size a test that can fail. A performance budget is a number checked in CI (the server that builds and tests every push):

    npm install --save-dev size-limit @size-limit/file   # the tool, plus its plugin for measuring plain files
    package.json — size-limit fails the build when a budget is exceeded
    {
      "scripts": { "size": "size-limit" },
      "size-limit": [
        { "path": "dist/assets/index-*.js", "limit": "75 kB" }
      ]
    }

    Pick numbers from where you are today plus a little room, and lower them as you improve. Before adding a dependency, check what it costs compressed, and whether you need all of it; Build Tools explains tree shaking, which removes the parts you don't import, and how to read the bundle a build produced.

    Practitioner§12

    Resource hints and caching

    In one sentenceResource hints tell the browser about work it will need soon, so it starts earlier. Caching lets it skip the work entirely next time. Both only help when used on the few things that matter.

    The hints

    Resource hints are <link> tags in the <head>. Each tells the browser something it would otherwise discover later:

    HintMeansUse for
    <link rel="preconnect" href="https://fonts.example.com" crossorigin>open the connection (DNS + TCP + TLS) now1–3 other servers you'll certainly use early (fonts, image CDN, API)
    <link rel="dns-prefetch" href="https://cdn.example.com">only look up the addresscheaper fallback for servers you might use
    <link rel="preload" href="…" as="…">download this file now, at high prioritylate-discovered files the first screen needs: the hero image (§05), a font (§07)
    <link rel="modulepreload" href="/assets/chart.js">download and parse this JavaScript modulemodules you know you'll import soon; Vite adds these for you
    <link rel="prefetch" href="/assets/checkout.js">download at low priority, when idlefiles the next page will probably need
    fetchpriority="high" / "low" on <img>, <script>, <link>adjust priority within its kindthe LCP image (high); a carousel's hidden slides (low)

    (The crossorigin on the font preconnect matters: font requests use CORS mode (§07), and the browser keeps CORS and non-CORS connections apart, so the connection must be opened in the same mode the font request will use.)

    A preconnect saves the round trips of setting up a connection (three of them in our model, 450 ms on the slow phone). A preload saves the discovery delay. A prefetch helps the next page, not this one. Turn them on in our waterfall model from §02 and watch the font row:

    Resource hints on the waterfall
    Model of one page load (not a measurement): requests share the connection's bandwidth equally. 150 ms round trip, 1.6 Mbit/s.
    page.html 30 KB
    connect
    style.css 20 KB
    app.js 150 KB
    hero.jpg 180 KB
    fonts.example.com/brand.woff2 40 KB
    connect
    screen
    fallback font
    brand font
    connect (DNS + TCP + TLS)waiting for the first bytedownloading
    first byte (TTFB)
    700 ms
    first paint
    1166 ms
    brand font shows
    2377 ms
    LCP (hero image)
    2817 ms

    Without hints, the browser learns it needs the font only after style.css has arrived and been applied, and then it still has to open a brand-new connection to another server (purple).

    Caching: the fastest request is the one you don't make

    On a repeat visit, the browser's HTTP cache can serve files without touching the network, if the response headers allowed it. The pattern every modern build uses has two halves:

    1. Hashed file names for assets. A build tool like Vite names files after a fingerprint of their content: index-3f9a1c.js. Change one character and the next build produces a new name. So an old name can be cached forever: its content will never change.
    2. HTML that's always checked. index.html keeps its name, so it must not be reused blindly. Otherwise users would keep loading an old page pointing to old asset names.
    ResponseCache-ControlEffect
    /assets/index-3f9a1c.js, .css, images, fonts with a hashpublic, max-age=31536000, immutablereused for a year with no request at all
    /index.html and other HTMLno-cachereused only after asking the server; if unchanged, the answer is a tiny 304 Not Modified

    Here's that policy as server code. Both versions serve a Vite dist/ folder:

    main.go
    package main
    
    import (
    	"log"
    	"net/http"
    	"strings"
    )
    
    const dist = http.Dir("dist")
    
    // cacheHeaders decides how long browsers and CDNs may reuse each response.
    func cacheHeaders(next http.Handler) http.Handler {
    	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    		if strings.HasPrefix(r.URL.Path, "/assets/") {
    			// Vite puts a content hash in these names: a new build = a new URL.
    			if isFile(dist, r.URL.Path) { // ← never cache a 404 for a year
    				w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
    			}
    		} else {
    			// HTML: reuse only after checking with the server (a cheap 304 if unchanged).
    			w.Header().Set("Cache-Control", "no-cache")
    		}
    		next.ServeHTTP(w, r)
    	})
    }
    
    func isFile(dir http.Dir, name string) bool {
    	f, err := dir.Open(name) // http.Dir cleans the path, so "../" can't escape dist/
    	if err != nil {
    		return false
    	}
    	defer f.Close()
    	st, err := f.Stat()
    	return err == nil && !st.IsDir()
    }
    
    func main() {
    	files := http.FileServer(dist) // also sends Last-Modified and answers 304s
    	log.Fatal(http.ListenAndServe(":8080", cacheHeaders(files)))
    }

    The full story of ETag, Last-Modified, stale-while-revalidate and CDN caching is in Caching §09 and HTTP §08. A Service worker can go further and serve the whole app offline; Browser Storage covers it.

    Staff-level§13

    Staff-level: RUM, attribution, bfcache and regressions

    In one sentenceA score tells you that a page is slow; you need data that tells you why. Collect field data with attribution, cut it by page type and device, stop regressions in CI, and remember the cheapest win of all: pages restored instantly from the back/forward cache.

    1. RUM that points at causes

    A dashboard that says "INP p75: 380 ms" starts a meeting, not a fix. The web-vitals library's attribution build adds the diagnosis to each report:

    vitals.js — with attribution
    import { onCLS, onINP, onLCP } from "web-vitals/attribution";
    
    // send(name, value, details): your own helper, like §04's send(), that POSTs one beacon with navigator.sendBeacon
    
    onLCP(({ value, attribution: a }) => send("LCP", value, {
      element: a.target,                          // CSS selector of the LCP element
      ttfb: a.timeToFirstByte, loadDelay: a.resourceLoadDelay,
      loadDuration: a.resourceLoadDuration, renderDelay: a.elementRenderDelay,   // the four parts from §05
    }));
    
    onINP(({ value, attribution: a }) => send("INP", value, {
      target: a.interactionTarget, type: a.interactionType,                       // what was clicked, "pointer" or "keyboard"
      inputDelay: a.inputDelay, processing: a.processingDuration, presentation: a.presentationDelay,  // §08
      scripts: a.longAnimationFrameEntries?.flatMap((f) => f.scripts.map((s) => s.sourceURL)),         // who ran
    }));
    
    onCLS(({ value, attribution: a }) => send("CLS", value, { element: a.largestShiftTarget }));

    Field names checked against web-vitals 6.2 (the current version at the time of writing; they were the same in v4). Check the README of the version you install, since major versions sometimes rename fields.

    Now the meeting has an agenda: "INP is bad on the product page, on Android, when tapping button.add-to-cart, and 70% of it is input delay caused by chat-widget.js."

    2. Segment, don't average

    • Per page type (product, search, checkout), not per site: a fast home page hides a slow checkout.
    • Per device class (mobile and desktop at least; Chromium's navigator.deviceMemory and navigator.connection.effectiveType give rough hints). The phone segment is almost always the one failing.
    • 75th percentile per segment, tracked over time, with enough samples. A p75 from 40 visits jumps around; don't react to it.
    • Field data lags. CrUX is a rolling 28-day window, so a fix shipped today takes weeks to show fully there. Your own RUM shows it in a day.

    3. Stop regressions before they ship

    Performance usually dies by a thousand cuts: a new dependency, a second analytics tag, a bigger hero image. What holds the line:

    • Byte budgets in CI (§11): cheap, deterministic, catch most regressions.
    • Lab tests on key pages in CI (Lighthouse CI or WebPageTest) with a throttled phone profile, comparing medians of several runs against a threshold.
    • A RUM alert on the p75 of each vital per page type, compared week over week, so what slips past CI is caught within days.
    • An owner for third-party scripts. Each tag added through a tag manager runs on your main thread. Require a reason and an expiry date for every one, and measure their cost: DevTools' Network panel can block a single URL so you can compare with and without it.

    4. The back/forward cache: instant navigations for free

    When a user leaves a page, the browser can keep the whole page, with its JavaScript state, frozen in memory. Press Back and it's restored instantly: no network, no parsing, no rendering. That's the back/forward cache (bfcache). Back and forward navigations are a large share of all navigations, so being eligible is one of the biggest wins available, and it costs nothing, as long as you don't block it.

    Common blockers:

    • unload event listeners. Use pagehide (or visibilitychange) instead; unload is also unreliable on mobile.
    • Cache-Control: no-store on the HTML, which historically made Chromium skip the bfcache (newer versions relax this in some cases).
    • Open connections such as an active WebSocket or IndexedDB transaction when the user navigates away; close them on pagehide and reopen on pageshow.
    react to being frozen and restored
    window.addEventListener("pageshow", (event) => {
      if (event.persisted) refreshPrices();   // ← true when restored from the bfcache: data may be stale
    });

    Chrome DevTools → Application → Back/forward cache has a "Test" button that tells you whether a page is eligible and, if not, exactly why.

    5. Single-page apps: what the metrics see

    Core Web Vitals are measured per page load. In a Single-page app, a route change isn't a page load, so:

    • LCP only measures the first page the user landed on. Later routes have no LCP.
    • INP and CLS keep accumulating over the whole session across route changes, and are reported against the URL the visit started on.

    Chromium has been experimenting with a "soft navigations" API that would measure route changes as navigations; until that is standard, measure route changes yourself (time from click to the new view's main content) with performance.mark() and performance.measure(), and send them with your RUM.

    Practitioner§14

    Pitfalls gallery

    01

    loading="lazy" on the hero image

    A blanket rule ("all images lazy") or a component library default catches the most important image on the page. The request waits for layout instead of starting with the HTML (§05).

    ✕ The LCP image waits for layout
    <img src="/hero.avif" loading="lazy" alt="…">
    ✓ Requested immediately, high priority
    <img src="/hero.avif" width="1200" height="800" fetchpriority="high" alt="…">
    02

    Images without width and height

    The image starts at zero height and pushes the page down when it loads: a layout shift scored against you (§04 showed it live). Fix: always set both attributes (the file's real size is fine) and height: auto in CSS; aspect-ratio for boxes without an image.

    03

    One huge image for every screen

    A 2400-pixel photo for a 400-pixel phone slot is 639 KB instead of 88 KB in our measurement (§06). Fix: srcset with several widths and a sizes value that matches your CSS, plus AVIF or WebP.

    04

    Preloading everything

    Ten <link rel="preload"> tags compete with each other and with the LCP image, so nothing gets ahead. Fix: preload only late-discovered files that the first screen needs, usually one image and one or two fonts (§12).

    05

    A font preload without crossorigin

    <link rel="preload" href="/fonts/brand.woff2" as="font" type="font/woff2">  <!-- downloads twice -->

    Fonts are fetched in CORS mode; this preload isn't, so it can't be reused and the font is downloaded again (§07). Fix: add the crossorigin attribute.

    06

    Trusting your laptop and the Lighthouse score

    A 98 on a fast machine says little about a mid-range phone, and Lighthouse can't measure INP at all (§04). Fix: test with CPU and network throttling, and treat field data (CrUX or your RUM) at the 75th percentile as the real score.

    07

    Reporting averages

    In §03's "real visitors" data the average is 2.9 s, which sounds almost fine, yet 4 of the 20 visits (20%) took over 4 s, and the 75th percentile is 3.4 s. Fix: report the 75th percentile, per page type and device class.

    08

    Heavy work directly in a click or input handler

    Everything the handler does happens before the next paint, so it all counts toward INP (§08). Fix: paint feedback first and yield, mark React updates with startTransition, debounce input, move computation to a Web Worker (§09).

    09

    One bundle for the whole app

    Admin screens, editors and charts that most visitors never open still download and run on every page load. Fix: split routes and rarely used features with import() / React.lazy, and enforce a byte budget in CI (§11).

    10

    Banners inserted above the content

    Cookie notices, app promos and "free shipping" bars that appear after load push everything down (§10). Fix: overlay them with position: fixed, or reserve their space in the initial HTML.

    11

    Unhashed assets with long cache times, or hashed ones with none

    app.js cached for a year means users run old code after a deploy; app-3f9a1c.js with no-cache means a pointless check on every visit (§12). Fix: hashed names + max-age=31536000, immutable for assets, no-cache for HTML.

    12

    An unload listener

    window.addEventListener("unload", saveDraft);   // blocks the back/forward cache in many browsers

    Fix: use pagehide or visibilitychange, so Back can restore the page instantly from the bfcache (§13).

    Practitioner§15

    Check yourself

    Commit to an answer before you click. Each explanation points back to the section that covers it.

    QUESTION 1 / 7
    Which image is the problem?

    A product page's LCP is 4.1 s on phones. The LCP element is the hero image. Its tag is below. What's the first thing to change?

    <img src="/hero-2400.jpg" loading="lazy" alt="Kayak on a lake">
    QUESTION 2 / 7
    Lab says fine, field says poor

    Lighthouse on your laptop gives INP… nothing, and TBT is low. The field data (CrUX) shows INP 380 ms at p75. What's going on?

    QUESTION 3 / 7
    Which change counts as a layout shift?
    QUESTION 4 / 7
    Predict the INP parts

    The user taps while a 90 ms task is 30 ms in. The handler runs 40 ms. Style, layout and paint plus waiting for the next frame take 20 ms. Which part is biggest?

    QUESTION 5 / 7
    Why the crossorigin attribute?

    This preload downloads the font, and then the browser downloads it again. Why?

    <link rel="preload" href="/fonts/brand.woff2" as="font" type="font/woff2">
    QUESTION 6 / 7
    What does React.lazy actually save?
    const Editor = lazy(() => import("./Editor.jsx"));
    QUESTION 7 / 7
    Hashed file names and caching

    Your build emits app-3f9a1c.js. What Cache-Control should it get, and what should index.html get?

    Answered 0 / 7
    Foundations§16

    Cheat sheet

    🛍️
    Three questionsMain content fast? LCP ≤ 2.5 s. Responds fast? INP ≤ 200 ms. Stays still? CLS ≤ 0.1.
    📱
    p75, on phones, in the fieldJudge by real users at the 75th percentile. Use the lab (throttled) to find causes.
    🌐
    Round trips and bytesEvery connection costs ~3 round trips; on a slow phone, 180 KB is about a second.
    🖼️
    The LCP imageIn the HTML, never lazy, fetchpriority="high", right-sized via srcset, AVIF/WebP.
    💤
    Lazy below the foldloading="lazy" on everything the first screen doesn't show. 4.8 s → 2.1 s in our test.
    🔤
    FontsPreload 1–2 WOFF2 files with crossorigin; swap + metric-matched fallback, or optional.
    👆
    INP = delay + processing + presentationPaint feedback first, yield, startTransition, Web Workers, smaller DOM updates.
    📏
    Reserve spacewidth/height on images, aspect-ratio / min-height for embeds and ads, overlay banners.
    ✂️
    Less JavaScriptSplit routes and rare features with import() / React.lazy; budget bytes in CI.
    🗄️
    Cache by hashHashed assets: max-age=31536000, immutable. HTML: no-cache. Keep pages bfcache-eligible.
    If you want to…Do thisAvoid
    Speed up LCPfind the LCP element; request it early; shrink itlazy-loading it, CSS backgrounds, JS-inserted heroes
    Serve the right image sizesrcset + sizes matching your CSSone 2400-px file for everyone
    Load a web fontWOFF2 subset, preload … crossorigin, font-displayicon fonts, 6 weights, third-party font hosts without preconnect
    Keep interactions fastyield after visible feedback; transitions; workerslong synchronous handlers
    Stop layout shiftsreserve space for anything lateinserting content above what the user is reading
    Load a heavy, rarely used featureReact.lazy + Suspense, preload on hoverimporting it in the main bundle
    Reach another server early<link rel="preconnect"> for 1–3 critical originspreconnecting to everything
    Measure real usersweb-vitals (+ attribution) → your RUM, p75 per segmentaverages, one Lighthouse run on your laptop
    Make repeat visits fasthashed assets + immutable, HTML no-cachelong caching on unhashed file names
    Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
    Up next in Frontend & BrowserBuild Tools & Bundlers
    Read →