Frontend / Performance / 01_core_web_vitals.md

Core Web Vitals

Updated 8 interview angles 10 min read source
On this page8
  1. The mechanism: three different moments
  2. LCP: bytes, and when they were discovered
  3. INP: three phases, three different fixes
  4. CLS: reserve the space
  5. Where it breaks in production
  6. The trade not taken
  7. Related
  8. Interview angle

Core Web Vitals

Article.

Your Lighthouse score is 98. Users say the site feels slow. Both are true, and understanding why is most of what there is to know about these metrics.

Core Web Vitals are three numbers — LCP, INP, CLS — defined at the 75th percentile of real users. That definition, not the thresholds, is where the interesting behaviour lives.

The mechanism: three different moments

The three vitals are not three views of “speed”. They measure three distinct phases, and each is moved by a different kind of work.

text
  load ──────────► interact ──────────► settle

  LCP                INP                  CLS
  when the main      how long from a      how much the
  content painted    tap to the next      layout jumped
                     painted frame        unexpectedly

  fix: bytes and     fix: main-thread     fix: reserve
  their ordering     work                 space upfront
Metric Good (p75) Poor
LCP ≤ 2.5s > 4.0s
INP ≤ 200ms > 500ms
CLS ≤ 0.1 > 0.25

All three “Good” makes the page Good; any one “Poor” makes it Poor.

Why the lab score lied

Lighthouse cannot measure INP, because there is no user. It reports Total Blocking Time instead, which is main-thread blocking during load — a proxy that correlates loosely and misses everything that happens after the page settles.

So a page can score 98 while every click takes 400ms, and nothing in the lab run will tell you. That is the single most useful fact in this area:

Lab (Lighthouse) Field (CrUX, web-vitals)
LCP simulated real
INP not measured real
CLS load only whole session
Conditions one throttled run every device and network

Field data is the metric; lab data is a debugging tool. Reversing those is how a team spends a quarter improving a number that no user experiences.

p75 means you optimise the tail

The metric is the 75th percentile of your real users. Halving the time for the fastest half of your traffic moves it by exactly nothing.

text
p50  ████                      already fine
p75  ████████████              <- the metric
p95  ████████████████████████  the people suffering most

The users at p75 are on mid-range Android devices, on congested mobile networks, with a cold cache, and often with more third-party script than your own. Optimising for the developer’s laptop is optimising the part of the distribution the metric ignores.

This reframes the whole exercise: Core Web Vitals is a distribution problem, so segment before you optimise — by device class, connection, country and route. An aggregate p75 hides which population is dragging it.

LCP: bytes, and when they were discovered

LCP is the render time of the largest text block or image in the initial viewport. It is almost always one of four things:

  1. Slow TTFB — the server or CDN. Everything downstream inherits it.
  2. Render-blocking CSS or JS in <head>.
  3. The image discovered late — referenced from CSS or injected by JS, so the preload scanner never saw it.
  4. Client-side rendering — nothing paints until the bundle runs.

The hero image

html
<!-- The cheapest LCP win there is. -->
<img src="hero.avif" fetchpriority="high" width="1200" height="630" alt="">

fetchpriority="high" tells the browser to spend its connection budget here first. Its opposite is the classic own goal:

html
<img src="hero.avif" loading="lazy">   <!-- delays the LCP element -->

Lazy-loading the hero defers the exact resource the metric is timing. Lazy-load everything below the fold and nothing above it.

The debugging loop is mechanical: find the LCP element in DevTools, look at when its resource started rather than how long it took, and fix whatever delayed the start.

INP: three phases, three different fixes

The part most people get wrong. INP is not “how long your event handler ran” — it is the whole path from input to the next painted frame, and it has three segments.

text
  tap
   │  input delay        main thread was busy with
   │                     something else entirely
   │  processing time    your handler runs
   │  presentation delay browser does style, layout,
   ▼                     paint for the new frame
  next frame painted

Which phase is it

They have different causes and different fixes:

Phase Usual cause Fix
Input delay a long task already running break up long tasks
Processing the handler does too much yield, defer, move off-thread
Presentation huge re-render or layout render less, virtualise

Measure which phase dominates before optimising, because the three fixes do not substitute for each other. Optimising a handler that runs in 8ms will not help when the input delay is 300ms because an analytics script owns the main thread.

ts
import { onINP } from "web-vitals/attribution";

onINP(({ value, attribution: a }) => {
  send({
    value,
    target: a.interactionTarget,       // which element
    inputDelay: a.inputDelay,          // phase 1
    processingDuration: a.processingDuration,  // phase 2
    presentationDelay: a.presentationDelay,    // phase 3
  });
});

The /attribution build is the difference between “INP is 420ms” and “INP is 420ms, 310ms of it input delay, on the cookie banner’s button”. Ship it.

Yielding properly

ts
async function processAll(items: Item[]) {
  for (const item of items) {
    doWork(item);
    // Yields to let pending input run, then resumes AHEAD of
    // other queued tasks. setTimeout(0) goes to the back.
    await scheduler.yield();
  }
}

scheduler.yield() is the current tool and it beats the old await new Promise(r => setTimeout(r, 0)) trick, which yields but sends your continuation to the back of the queue — so a busy page starves the work you were trying to finish.

React specifics, as of 2026

useTransition and useDeferredValue mark an update non-urgent so it cannot block typing. Those are still correct.

What is not current: scattering useMemo and useCallback for performance. React Compiler 1.0 (stable since 2025-10) auto-memoizes, so manual memoization in a compiled codebase is noise that makes the code harder to read without making it faster. Treat pre-2025 advice on this as outdated guidance rather than merely an old version number. See React.

CLS: reserve the space

Every layout shift is something arriving without a reservation.

html
<img src="p.avif" width="1200" height="630" alt="">
css
img { max-width: 100%; height: auto; }   /* keeps the aspect ratio */

.ad-slot { min-height: 250px; }          /* before the ad loads */

@font-face {
  font-family: Inter;
  font-display: swap;
  size-adjust: 107%;                     /* match the fallback metrics */
}

The width/height attributes are what reserve the box, even when CSS resizes the image afterwards — the browser computes the aspect ratio from them before any bytes arrive.

size-adjust is the one people miss. font-display: swap avoids invisible text and guarantees a reflow when the real font lands; matching the fallback’s metrics means the swap happens without moving anything.

And the definition matters: since 2021 CLS is the largest shift cluster in any 5-second window, not a lifetime sum. A long-lived SPA is not punished for accumulating small shifts over an hour — but one bad moment sets the score.

Where it breaks in production

Green in the lab, red in the field

Covered above and worth stating as a checklist, because the gap has specific causes: the lab has no INP, no third-party consent flow, no cold cache, a fixed device profile, and often an ad blocker’s worth of scripts missing. Anything that only runs for real users is invisible to it.

The thing that only loads for real users

A consent banner, an A/B testing script, a session recorder, a chat widget. They are frequently excluded from the staging environment the lab runs against, and they are frequently the largest main-thread cost on the page.

Audit the third-party budget as a first-class number. A tag manager that lets marketing add scripts without review is an unbounded performance liability with no owner.

Hydration: painted but dead

Server rendering paints early, so LCP looks excellent. Then the bundle downloads, parses and hydrates, and during that window every click queues. Good LCP with terrible INP is the signature of a hydration problem, and no amount of image optimisation touches it.

The fixes are structural: ship less JavaScript, split by route, and move work to the server — which is the argument for React Server Components.

bfcache silently disabled

Back and forward navigations can be instant, restoring the whole page from memory, and those navigations count in field data. Two common things turn it off:

  • an unload event listener anywhere on the page
  • Cache-Control: no-store on the main document

Use pagehide instead of unload, and reserve no-store for documents that genuinely must never be reused. Losing bfcache degrades a large slice of real navigations while every lab number stays identical.

Optimising the median

The dashboard shows the average improving and the metric does not move, because the metric is p75. Segment by device and network, find which population sits above the threshold, and fix that one.

A budget with no gate

“We have a performance budget” and nothing enforces it. Bundle size drifts the first week someone is busy.

yaml
# CI: fail the build, do not just report
- run: npx lighthouse-ci autorun --assert.preset=lighthouse:recommended
- run: npx bundlesize

A budget without enforcement is a wish. The senior answer is always budget plus measurement plus a gate, and the gate is the part that survives a deadline.

The trade not taken

These are proxies. They were chosen because they are measurable from a browser without knowing anything about your product, and that generality is exactly what makes them incomplete.

A checkout that scores 100 and loses people at the payment step has a real problem the vitals cannot see. A documentation site with an LCP of 2.7s that answers the question in one page is not actually failing anyone. Optimising to the threshold rather than to the experience is the failure mode, and it is common precisely because the threshold is the thing that gets reported.

The other honest trade: sometimes the right answer is not better-scheduled JavaScript but less JavaScript. A server-rendered page with a small amount of progressive enhancement beats an SPA with a carefully tuned hydration strategy on every one of these metrics, and it is less code. That option is worth naming out loud before spending a quarter on scheduling primitives.

Interview angle 8

  • “What are the Core Web Vitals?” — LCP for loading, INP for responsiveness, CLS for visual stability, each at the 75th percentile of real users. The percentile is the important part: it is a distribution, so improving the fast half of your traffic moves the number by nothing.
  • “Lighthouse says 98 and users say it is slow.” — Lighthouse cannot measure INP, because there is no user; it reports Total Blocking Time as a proxy for main-thread work during load. Every post-load interaction problem is invisible to it, along with consent banners, cold caches and real devices.
    • Follow-up: “So what do you trust?” — field data from CrUX or the web-vitals library as the metric, and lab tools purely for debugging and regression gating. Field is what users experience and what search ranking uses.
  • “INP is bad. Where do you look?” — first at which of its three phases dominates: input delay from a long task already running, processing time in your handler, or presentation delay from a huge re-render. They have different fixes and optimising the wrong one does nothing.
    • Follow-up: “How do you find out which?” — the /attribution build of web-vitals reports all three phases plus the element that was interacted with, so you get “310ms of input delay on the cookie banner” instead of a single number.

Under pressure

  • “Good LCP, terrible INP — what does that tell you?” — usually hydration. The server-rendered markup painted quickly, then the bundle downloaded and hydrated, and clicks during that window queue. The fix is less JavaScript, not better images.
  • “How do you fix CLS?” — reserve the space before the content arrives: width/height or aspect-ratio on images, min-height on ad and iframe slots, and font-display: swap with size-adjust matching the fallback so the font swap does not move anything.
  • “What quietly breaks field metrics with no lab signal?” — losing bfcache. An unload listener or Cache-Control: no-store on the document disables it, and instant back-navigations become full loads for a large share of real traffic while every lab number stays the same.
  • “What performance budget would you set?” — per route, per metric: LCP under 2.5s, INP under 200ms, CLS under 0.1 at p75, plus a transferred-JS ceiling — and a CI gate that fails the build. A budget without enforcement drifts the first week someone is busy.
  • “When are these metrics the wrong thing to optimise?” — when they are green and the product is still failing people, or when hitting a threshold costs more than the experience it buys. They are proxies chosen for being measurable without knowing your product, and that generality is the limit.