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.
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.
p50 ████ already fine
p75 ████████████ <- the metric
p95 ████████████████████████ the people suffering mostThe 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:
- Slow TTFB — the server or CDN. Everything downstream inherits it.
- Render-blocking CSS or JS in
<head>. - The image discovered late — referenced from CSS or injected by JS, so the preload scanner never saw it.
- Client-side rendering — nothing paints until the bundle runs.
The hero image
<!-- 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:
<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.
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 paintedWhich 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.
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
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.
<img src="p.avif" width="1200" height="630" alt="">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
unloadevent listener anywhere on the page Cache-Control: no-storeon 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.
# CI: fail the build, do not just report
- run: npx lighthouse-ci autorun --assert.preset=lighthouse:recommended
- run: npx bundlesizeA 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.
Related
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-vitalslibrary as the metric, and lab tools purely for debugging and regression gating. Field is what users experience and what search ranking uses.
- Follow-up: “So what do you trust?” — field data from CrUX or the
- “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
/attributionbuild ofweb-vitalsreports 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.
- Follow-up: “How do you find out which?” — the
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/heightoraspect-ratioon images,min-heighton ad and iframe slots, andfont-display: swapwithsize-adjustmatching the fallback so the font swap does not move anything. - “What quietly breaks field metrics with no lab signal?” — losing bfcache.
An
unloadlistener orCache-Control: no-storeon 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.