Performance · 9 minute read
Core Web Vitals without the guesswork
Three metrics, measured on real visitors at the 75th percentile, over a rolling 28 days. Your lab score is a model of them, not a measurement.
| Metric | Good | Needs work | Poor | What it measures |
|---|---|---|---|---|
| LCP Largest Contentful Paint | ≤ 2.5 s | 2.5–4.0 s | > 4.0 s | When the largest block of text or the main image finishes rendering. A proxy for "the page looks ready". |
| INP Interaction to Next Paint | ≤ 200 ms | 200–500 ms | > 500 ms | The worst interaction latency across the visit — tap or key press to the next frame drawn. Replaced FID in March 2024 and is much harder to pass. |
| CLS Cumulative Layout Shift | ≤ 0.1 | 0.1–0.25 | > 0.25 | How much visible content moves without the user causing it. Unitless; the product of shift distance and the fraction of the viewport affected. |
Fixing LCP
Break the number into four parts and find which one dominates — guessing here wastes the most time.
| Phase | Typical cause | Fix |
|---|---|---|
| Time to first byte | Slow application or no caching | Page caching, a CDN, a faster database query. Target under 800 ms. |
| Resource load delay | The LCP image is discovered late | Put it in the HTML rather than CSS or JS, add fetchpriority="high", and never loading="lazy" on it. |
| Resource load time | The image is too large | AVIF or WebP, correct dimensions, srcset. This is usually where the seconds are. |
| Element render delay | Render-blocking CSS or JS, or a font swap | Inline critical CSS, defer scripts, use font-display: swap and preload the font file. |
Fixing INP
INP is a main-thread problem. Long tasks block the next frame, and no amount of image optimisation helps.
- Break tasks longer than 50 ms into pieces and yield between them —
await scheduler.yield()where available, otherwise asetTimeout(0)boundary. - Render feedback before doing the work: update the UI, yield, then compute. Perceived latency is what is measured.
- Audit third-party scripts. Tag managers, chat widgets and consent banners are the most common cause of a failing INP, and they are the easiest to defer.
- Avoid layout thrashing — reading a geometry property after writing to the DOM forces a synchronous reflow inside the interaction.
Fixing CLS
- Set
widthandheighton every image and iframe, or anaspect-ratioin CSS. This is the single biggest cause. - Reserve space for anything injected later: ads, embeds, cookie banners, "related posts".
- Preload web fonts and match fallback metrics with
size-adjustorfont-size-adjustso the swap does not reflow text. - Never insert content above existing content unless the user asked for it.
- Animate
transformandopacity, which do not trigger layout, rather thantop,heightormargin.
Measuring the real thing yourself
// The same library Chrome uses, ~2 KB. Report to your own endpoint.
import { onLCP, onINP, onCLS } from 'web-vitals';
const send = (metric) => {
navigator.sendBeacon('/vitals', JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
path: location.pathname,
}));
};
onLCP(send);
onINP(send);
onCLS(send);
Collecting your own field data gets you per-page numbers immediately, rather than waiting 28 days for a rolling average over URLs that may not have enough traffic to appear at all.