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.
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.
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:
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.
LCP ≤ 2.5 s
INP ≤ 200 ms
CLS ≤ 0.1
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.
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:
- DNS: look up the server's address from its name (often one round trip, sometimes cached).
- TCP: open a connection (one round trip).
- TLS: agree on encryption for HTTPS (one round trip with TLS 1.3).
- 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:
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:
Fewer round trips
Fewer bytes
Better order
Core Web Vitals: LCP, INP and CLS
| Metric | Question it answers | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP | When is the main content visible? | ≤ 2.5 s | 2.5–4 s | > 4 s |
| INP | How fast does the page respond to input? | ≤ 200 ms | 200–500 ms | > 500 ms |
| CLS | How much does the layout jump? | ≤ 0.1 | 0.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 CSSbackground-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.
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.
Measuring: lab vs field, PerformanceObserver and web-vitals
| Lab data | Field data | |
|---|---|---|
| Where from | A test you run: Lighthouse, the DevTools Performance panel, WebPageTest, a CI job | Real visitors' browsers: CrUX, your own RUM |
| Device and network | Whatever you choose (use a throttled phone profile) | Whatever your users have |
| Repeatable? | Yes: change one thing, measure again | No: 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 clicks | Yes |
| Good for | Debugging, comparing two versions, catching regressions in CI | Knowing 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.
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:
Notice three things:
- 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.
- An image without
widthandheightcauses 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. - 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.
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.
LCP: find the hero, then make it early and small
LCP in four parts
- Time to first byte: until the HTML starts arriving. Fix on the server: caching, a CDN close to users, less work per request (Caching).
- 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.
- Resource load duration: downloading the image's bytes. Fix: fewer bytes (§06), and less competition from other downloads.
- 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:
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.
| Variant | Hero request starts | LCP (median of 5) |
|---|---|---|
| six eager photos below the fold | 247 ms | 4.81 s |
…photos loading="lazy" (baseline) | 251 ms | 2.10 s |
hero with loading="lazy" | 566 ms | 2.21 s |
hero as a CSS background-image | 441 ms | 2.14 s |
| hero inserted by JavaScript | 1,143 ms | 2.23 s |
JavaScript + <link rel="preload"> | 192 ms | 2.06 s |
| AVIF (87 KB) instead of JPEG | 257 ms | 1.36 s |
| AVIF, 800 px wide (44 KB) | 258 ms | 0.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.
preloadrescues 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
<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. widthandheightso 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.
<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.
Images: formats, srcset, lazy loading
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:
| Width | PNG | JPEG (quality 80) | WebP (quality 75) | AVIF (quality 55) |
|---|---|---|---|---|
| 400 px | — | 26 KB | 19 KB | 14 KB |
| 800 px | — | 88 KB | 56 KB | 44 KB |
| 1200 px | 1,547 KB | 180 KB | 103 KB | 87 KB |
| 2400 px | — | 639 KB | 311 KB | 297 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.
| Format | Use it for | Notes |
|---|---|---|
| AVIF | photos | Smallest files; slower to encode. Supported in all current major browsers. |
| WebP | photos, images with transparency | Supported everywhere current; a safe default. |
| JPEG | photos, as the fallback | Universal. |
| PNG | screenshots, pixel art, when you need lossless (no detail thrown away) | Huge for photos: 1.5 MB above, 9× the JPEG. |
| SVG | logos, icons, simple illustrations | Text-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 (thewmeans "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:
<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">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.
Fonts without the flash or the jump
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:
- The CSS containing the
@font-facerule has downloaded and been parsed. - 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.
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-display | Block period (invisible) | Swap period | Good for |
|---|---|---|---|
block | about 3 s | until it arrives | icon fonts, where a fallback letter would be nonsense |
swap | none (or close to it) | until it arrives | brand headings that must use the brand font |
fallback | about 100 ms | about 3 s | body text: short wait, then commit |
optional | about 100 ms | none | body text when the fallback is acceptable; zero shift |
auto (default) | browser's choice, usually like block | don'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:
| Setup | Font request starts → arrives | Layout shift (CLS) |
|---|---|---|
font-display: swap | 358 → 936 ms | 0.039 |
font-display: block | 342 → 908 ms | 0.039 (!) |
font-display: fallback | 355 → 919 ms | 0.039 |
font-display: optional | 341 → 908 ms | 0 (the font arrived too late, so this page view kept the fallback) |
swap + <link rel="preload"> | 191 → 766 ms | 0.039 |
swap + metric-matched fallback (size-adjust: 82%) | 336 → 904 ms | 0 |
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:
blockshifted just as much asswap. 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
<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:
@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,.ttfand.eot. - Subset. A full font may contain thousands of characters for many languages. Keep only what you use, and split by script with
unicode-rangeso 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,
preconnectto its file server (§12).
INP: from tap to next paint
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:
1// already running: a 140 ms timer callback2setTimeout(sendAnalyticsBatch, 0);34button.addEventListener("click", () => {5 const cart = addToCart(item); // 120 ms6 badge.textContent = cart.count; // DOM write7});
| Part | From → to | Typical cause |
|---|---|---|
| Input delay | input → your first handler starts | other code is running: a timer, a third-party script, React Hydration, another handler |
| Processing time | first handler starts → last handler ends | your handlers: heavy logic, re-rendering a big React tree, synchronous storage or layout reads |
| Presentation delay | handlers end → next frame on screen | style, 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:
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:
- Idle, 10 ms, one line. Every part is small; the total is a few dozen milliseconds, well under 200. That's a good interaction.
- 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,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
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 });Fixing INP: paint first, yield, do less
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.
button.addEventListener("click", () => {
const cart = addToCart(item); // 120 ms
badge.textContent = cart.count;
button.textContent = "Added ✓";
});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 ✓";
});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 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;
}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:
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:
- 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
| Problem | Fix |
|---|---|
| A handler runs on every key press or pointer move | Debounce 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 loop | Split 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 changed | Find 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
asyncordefer, 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.
CLS: how shifts are scored, and how to stop them
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.
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.
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). transformanimations. Moving an element withtransformdoesn't change layout, so nothing "shifts". Animatetransform, nevertopormargin(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
| Cause | Fix |
|---|---|
| Images and videos without dimensions | width and height attributes, CSS height: auto (§06) |
| Ads, embeds, iframes that size themselves when they load | Reserve 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 in | Metric-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, margin | Animate transform instead |
.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 */Ship less JavaScript: code splitting and budgets
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():
emojiButton.addEventListener("click", async () => {
const { openPicker } = await import("./emoji-picker.js"); // ← downloaded on first click
openPicker(textarea);
});In React: React.lazy and Suspense
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:
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:
| Build | Main JavaScript (gzip) | Loaded later | Ready to use on the slow phone |
|---|---|---|---|
| picker imported normally | 93.6 KB | — | 1,049 ms |
picker with React.lazy | 69.4 KB | 24.7 KB, on first click | 892 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{
"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.
Resource hints and caching
The hints
Resource hints are <link> tags in the <head>. Each tells the browser something it would otherwise discover later:
| Hint | Means | Use for |
|---|---|---|
<link rel="preconnect" href="https://fonts.example.com" crossorigin> | open the connection (DNS + TCP + TLS) now | 1–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 address | cheaper fallback for servers you might use |
<link rel="preload" href="…" as="…"> | download this file now, at high priority | late-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 module | modules you know you'll import soon; Vite adds these for you |
<link rel="prefetch" href="/assets/checkout.js"> | download at low priority, when idle | files the next page will probably need |
fetchpriority="high" / "low" on <img>, <script>, <link> | adjust priority within its kind | the 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:
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:
- 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. - HTML that's always checked.
index.htmlkeeps its name, so it must not be reused blindly. Otherwise users would keep loading an old page pointing to old asset names.
| Response | Cache-Control | Effect |
|---|---|---|
/assets/index-3f9a1c.js, .css, images, fonts with a hash | public, max-age=31536000, immutable | reused for a year with no request at all |
/index.html and other HTML | no-cache | reused 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:
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: RUM, attribution, bfcache and regressions
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:
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.deviceMemoryandnavigator.connection.effectiveTypegive 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:
unloadevent listeners. Usepagehide(orvisibilitychange) instead;unloadis also unreliable on mobile.Cache-Control: no-storeon 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
pagehideand reopen onpageshow.
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.
Pitfalls gallery
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).
<img src="/hero.avif" loading="lazy" alt="…"><img src="/hero.avif" width="1200" height="800" fetchpriority="high" alt="…">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.
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.
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).
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.
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.
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.
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).
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).
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.
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.
An unload listener
window.addEventListener("unload", saveDraft); // blocks the back/forward cache in many browsersFix: use pagehide or visibilitychange, so Back can restore the page instantly from the bfcache (§13).
Check yourself
Commit to an answer before you click. Each explanation points back to the section that covers it.
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">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?
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?
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">const Editor = lazy(() => import("./Editor.jsx"));Your build emits app-3f9a1c.js. What Cache-Control should it get, and what should index.html get?
Cheat sheet
fetchpriority="high", right-sized via srcset, AVIF/WebP.loading="lazy" on everything the first screen doesn't show. 4.8 s → 2.1 s in our test.crossorigin; swap + metric-matched fallback, or optional.startTransition, Web Workers, smaller DOM updates.width/height on images, aspect-ratio / min-height for embeds and ads, overlay banners.import() / React.lazy; budget bytes in CI.max-age=31536000, immutable. HTML: no-cache. Keep pages bfcache-eligible.| If you want to… | Do this | Avoid |
|---|---|---|
| Speed up LCP | find the LCP element; request it early; shrink it | lazy-loading it, CSS backgrounds, JS-inserted heroes |
| Serve the right image size | srcset + sizes matching your CSS | one 2400-px file for everyone |
| Load a web font | WOFF2 subset, preload … crossorigin, font-display | icon fonts, 6 weights, third-party font hosts without preconnect |
| Keep interactions fast | yield after visible feedback; transitions; workers | long synchronous handlers |
| Stop layout shifts | reserve space for anything late | inserting content above what the user is reading |
| Load a heavy, rarely used feature | React.lazy + Suspense, preload on hover | importing it in the main bundle |
| Reach another server early | <link rel="preconnect"> for 1–3 critical origins | preconnecting to everything |
| Measure real users | web-vitals (+ attribution) → your RUM, p75 per segment | averages, one Lighthouse run on your laptop |
| Make repeat visits fast | hashed assets + immutable, HTML no-cache | long caching on unhashed file names |