Core Web Vitals: What to Measure and What to Actually Fix
LCP, INP and CLS explained with their thresholds, the specific causes of each, and how to tell lab data from the field data Google actually ranks on.
Table of contents
- Lab data is not field data
- LCP: the largest element above the fold
- INP: responsiveness, and it replaced FID
- CLS: unexpected movement
- A practical order of work
- Frequently asked questions
- How much do Core Web Vitals affect rankings?
- Why is my Lighthouse score 100 but my field data poor?
- Does a heavy third-party script hurt me?
- Is Time to First Byte a Core Web Vital?
- Related reading
- References
Three metrics, three thresholds, and a measurement distinction that determines whether your work counts.
| Metric | Good | Needs work | Poor |
|---|---|---|---|
| LCP — Largest Contentful Paint | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| INP — Interaction to Next Paint | ≤ 200ms | ≤ 500ms | > 500ms |
| CLS — Cumulative Layout Shift | ≤ 0.1 | ≤ 0.25 | > 0.25 |
The threshold is measured at the 75th percentile of real users. Your median being fine is not enough.
Lab data is not field data#
This is the distinction most people miss. Lighthouse in devtools gives you lab data — one load, your machine, your network, simulated throttling. Google ranks on field data from the Chrome User Experience Report: real users, real devices, real networks, 28-day rolling window.
They diverge, and the field data is the one that counts. Lighthouse cannot even measure INP, because INP requires real interactions.
Collect field data yourself:
import { onLCP, onINP, onCLS } from 'web-vitals';
function report({ name, value, rating }) {
navigator.sendBeacon('/api/vitals', JSON.stringify({ name, value, rating }));
}
onLCP(report);
onINP(report);
onCLS(report);sendBeacon is the right transport — it survives page unload, which fetch may not.
LCP: the largest element above the fold#
Usually a hero image, a heading, or a background image. Four causes, in the order they usually matter:
Slow server response. LCP cannot be faster than your TTFB. A statically generated page served from a CDN edge starts at ~50ms; a server-rendered page hitting a database starts at 300ms+. This is why moving a page from dynamic to static rendering is often the single largest LCP win available.
The resource is discovered late. If the LCP image is referenced from CSS or injected by JavaScript, the browser cannot start fetching it until late. Preload it, or better, put it in the HTML:
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />Render-blocking resources. A synchronous script or a large stylesheet in <head> delays first paint. Defer scripts and inline the critical CSS.
The image is too large. Serve AVIF or WebP, and get sizes right so a phone does not download a desktop-sized file.
INP: responsiveness, and it replaced FID#
INP measures the time from an interaction to the next frame that reflects it — across the whole visit, reported at the 98th percentile of interactions. It is much harder to pass than FID was, because FID only measured input delay on the first interaction.
The cause is almost always long tasks on the main thread.
// Blocks for seconds; every click during that window has terrible INP
const results = hugeArray.map(expensiveTransform);Fixes, in order of effect:
- Break up the work with
await scheduler.yield()between chunks, so the browser can paint and respond. - Move it off-thread into a Web Worker for genuinely heavy computation.
- Ship less JavaScript. Every kilobyte is parse, compile and execute time on the main thread. Route-level code splitting and avoiding barrel imports both help — a single mis-configured barrel import can add tens of kilobytes to every page.
- Do less on interaction. Debounce input handlers; do not re-render a 5,000-row list on every keystroke.
CLS: unexpected movement#
Four causes, all preventable:
Images without dimensions. Always set width and height (or an aspect-ratio). The browser then reserves the box before the bytes arrive.
Web fonts swapping. A fallback font with different metrics reflows the text. font-display: optional avoids the swap entirely; size-adjust and ascent-override can match the fallback's metrics to the web font.
Content injected above existing content. A cookie banner, an ad slot, or an "you have 3 new messages" bar. Reserve the space, or position it as an overlay.
Animating layout properties. Animating height, top or margin triggers layout on every frame. Animate transform and opacity, which are composited and never shift anything.
A practical order of work#
- Get real field data. Without it you are guessing.
- Fix CLS first — it is usually the cheapest and most mechanical.
- Then LCP, starting with TTFB and image delivery.
- Then INP, which usually means shipping less JavaScript.
Frequently asked questions#
How much do Core Web Vitals affect rankings?#
They are a real but modest signal, and they act as a tiebreaker rather than a primary factor. Relevance and content quality dominate. That said, the user-experience benefit is worth it independently of ranking.
Why is my Lighthouse score 100 but my field data poor?#
Because your machine is fast and your users' are not, and because Lighthouse loads a cold page once with no interactions. Field data includes slow phones and repeat visits.
Does a heavy third-party script hurt me?#
Yes, substantially — analytics, chat widgets and tag managers are among the most common causes of poor INP. Load them lazily, and audit whether each one earns its cost.
Is Time to First Byte a Core Web Vital?#
No, but it is a strong input to LCP. Improving TTFB improves LCP almost one-for-one.
Related reading#
- The JavaScript Event Loop — what a long task is
- Next.js Image Optimization
- React Performance