Most Core Web Vitals work fails for the same reason: the team optimises the number they can see on their own laptop, not the number Google is actually looking at. Those are two different measurements, taken from two different populations, and they routinely disagree.
Here is what the three metrics measure, where the thresholds sit, and — for each one — the handful of changes that reliably move real-user data rather than a lab score.
The three metrics and their thresholds
- Largest Contentful Paint (LCP) — when the biggest above-the-fold element finishes rendering. Good is
≤ 2.5s, poor is> 4.0s. - Interaction to Next Paint (INP) — the latency of a page's interactions, reported close to the worst one. Good is
≤ 200ms, poor is> 500ms. It replaced First Input Delay as a Core Web Vital in March 2024, and it is much harder to pass, because FID only measured input delay while INP measures the whole round trip to the next frame. - Cumulative Layout Shift (CLS) — how much visible content moves without the user causing it. Good is
≤ 0.1, poor is> 0.25.
All three are assessed at the 75th percentile of page loads, segmented by mobile and desktop, over a rolling 28-day window. That has two consequences people miss. First, your median user's experience is irrelevant — the slowest quarter sets your score. Second, a fix you shipped last week will not show up in full for a month.
Field data versus lab data
Lighthouse and PageSpeed Insights' "performance score" are lab measurements: one simulated load, on a throttled connection, in a clean browser, with no extensions and no cache. Search Console and the Chrome User Experience Report are field measurements: what actually happened to real Chrome users on real devices.
Lighthouse cannot measure INP at all, because there is no user to interact with the page. It substitutes Total Blocking Time, which correlates with INP but is not the same thing. A site can score 100 in Lighthouse and still fail INP in the field — this is common on sites with heavy client-side interactivity that the lab run never triggers.
LCP: break it into four parts before touching anything
LCP is not one thing. It decomposes into four sub-parts that add up to the total, and the fix is completely different depending on which one dominates:
- Time to first byte — server and network latency before any HTML arrives.
- Resource load delay — the gap between the first byte and the browser starting to fetch the LCP resource.
- Resource load duration — how long that fetch takes.
- Element render delay — the gap between the resource finishing and the pixels appearing.
On most sites we audit, the dominant sub-part is load delay — the browser knows the HTML is there but has not discovered the hero image yet, usually because it is injected by JavaScript, set as a CSS background, or lazy-loaded. Lazy-loading the LCP image is the single most common self-inflicted performance wound on the web.
html
<!-- Discoverable in the initial HTML, eagerly loaded, prioritised. -->
<img
src="/hero-960.avif"
srcset="/hero-640.avif 640w, /hero-960.avif 960w, /hero-1440.avif 1440w"
sizes="(max-width: 768px) 100vw, 640px"
width="960" height="640"
fetchpriority="high"
decoding="async"
alt="…"
>
<!-- If the image is on another origin, pay the handshake early. -->
<link rel="preconnect" href="https://images.example-cdn.com" crossorigin>If render delay dominates instead, the resource arrived on time and something is blocking paint — render-blocking CSS, a font that is still swapping, or a framework that will not paint until hydration finishes. If TTFB dominates, no front-end change will save you; that is caching, origin configuration or a slow database query.
INP: it is almost always your own JavaScript
An interaction has three phases — input delay, processing, and presentation. INP is the sum. Failing INP nearly always means the main thread was busy when the user tapped, or the event handler itself did too much before yielding.
The reliable fixes, in the order they usually pay off:
- Break up long tasks. Anything over 50ms on the main thread is a window where input cannot be handled. Chunk the work and yield between chunks.
- Yield before doing expensive work in a handler. Update the UI first so the user sees a response, then do the rest after a yield.
- Cut hydration. Server components, islands, or simply not shipping a framework for a page that is mostly text. The cheapest interaction is the one that needed no JavaScript — a native
<details>element has an INP of effectively zero. - Audit third parties. Chat widgets, tag managers, heatmap recorders and consent banners are usually the top three long-task sources. Load them after interaction or on idle, not in the head.
- Shrink the DOM. Style and layout recalculation scales with node count.
content-visibility: autoon long off-screen sections helps when the page is genuinely large.
js
// Paint the visible change, then yield before the expensive part.
button.addEventListener("click", async () => {
setExpandedState(true); // cheap, visible, immediate
await scheduler.yield(); // let the browser paint + handle input
const rows = await buildLargeTable(); // the costly work, off the critical path
render(rows);
});CLS: reserve the space, every time
CLS is the most fixable of the three and the one most often left broken, because it is invisible to anyone testing on a warm cache and a fast connection. Four causes account for nearly all of it:
- Images and video without dimensions. Always set
widthandheightattributes (or anaspect-ratio). The browser then reserves the box before the file arrives. - Ads, embeds and iframes. Reserve a min-height for the slot. If the slot can collapse, let it collapse to the reserved size rather than to zero.
- Content injected above existing content. Cookie bars, promo banners and "you have unsaved changes" strips. Render them in the initial HTML or overlay them — never push the page down after paint.
- Font swap. When the web font has different metrics from the fallback, every line reflows on swap. Fix it with
size-adjust,ascent-overrideanddescent-overrideon the@font-facefallback.next/fontcalculates these automatically, which is most of why it exists.
The order we work in
- Pull 28 days of field data and find which metric actually fails, on which device class, on which template.
- Reproduce it in the lab and identify the dominant sub-part — not the symptom.
- Fix the single biggest contributor, deploy, and leave it alone.
- Wait for the field window to move. Repeat.
That last step is where most engagements go wrong. Teams ship eleven changes in one release, the number moves, and nobody knows which change did it — so nobody learns anything transferable to the next site.
What good looks like
Across the sites we ship, average LCP sits at 1.4 seconds and Lighthouse performance stays at 95 or above. Those are our build standards, not a one-off demo page: it is far cheaper to hold a performance budget from the first commit than to recover one after launch.
If you already have the site and the numbers are bad, the retrofit is still worth doing — it is just a different job, and it starts with the field data rather than the design. That is what our speed optimization work is.