Measuring Core Web Vitals properly

Almost every team we meet measures Core Web Vitals with a Lighthouse run on a developer laptop, gets a green score, and is then surprised by a Search Console warning. The gap is not a bug in either tool — it is that they measure different things. Getting performance work to pay off starts with measuring the right number, in the right place.

Lab and field are not the same measurement

Lab data (Lighthouse, PageSpeed Insights' lab section, WebPageTest) is a single simulated load on a specified device and network. It is reproducible, runnable in CI, and excellent for comparing two builds. It cannot tell you what your users experience, because it is one synthetic user.

Field data (Chrome User Experience Report, and your own Real User Monitoring) is what actually happened for real people on real devices and networks. This is what Google reports in Search Console and what determines whether a page passes. CrUX reports at the 75th percentile over a 28-day window — meaning a quarter of your visits can be worse than your reported number, and today's fix will take weeks to move the published figure.

The practical rule: optimize against lab data, judge success against field data. Teams that invert this either chase a synthetic score nobody experiences or wait a month to learn whether a change helped.

The three metrics, and what actually moves them

LCP — Largest Contentful Paint. When the biggest above-the-fold element renders. In React apps the usual causes of a bad LCP are, in order: the LCP element is client-rendered so it waits for the JS bundle; the hero image is unoptimized, unsized, or lazy-loaded (never lazy-load the LCP image); a render-blocking font or stylesheet delays paint; the server's response time is slow. Fixes go in that order — server-render the LCP element, preload the image with fetchpriority="high", use font-display: swap with preloaded fonts.

INP — Interaction to Next Paint. Replaced FID in 2024 and is the metric that catches React apps hardest, because it measures the full latency of interactions across the page: input delay, the event handler, and the paint that follows. Long tasks on the main thread are the enemy. Typical React causes: a state update that re-renders a large subtree synchronously, expensive work in an event handler, and hydration competing with early interactions. Fixes: the re-render hygiene covered in our re-render debugging notes, useDeferredValue/useTransition to keep urgent updates responsive, virtualization for long lists, and breaking up long tasks so the browser can paint.

CLS — Cumulative Layout Shift. Visible content moving after paint. Nearly always: images and embeds without dimensions, web fonts swapping metrics, banners injected above existing content, or content appearing when data arrives. Fixes: explicit width/height (or aspect-ratio) on every image, size-adjust-matched fallback fonts, and reserved space for anything asynchronous.

Set up field measurement in an afternoon

The web-vitals library is the reference implementation of these metrics — the same logic Chrome uses. Instrument it, send the results to whatever analytics you already run, and include the attribution data: which element was the LCP, which element and event caused the worst INP, which node shifted. Attribution is what makes field data actionable; without it you know page X is slow but not why.

Three habits that separate teams that improve from teams that measure:

  1. Segment by route, device, and connection. A single site-wide average hides everything. The failing pages are usually one template on mid-range Android.
  2. Watch percentiles, not means. A p75 and a p95 tell you whether you have a broad problem or a tail problem; a mean tells you nothing useful.
  3. Add a lab guard in CI. Lighthouse CI on a handful of representative routes, with a budget for bundle size and LCP, catches regressions the day they merge — a month before CrUX would.

Interpreting the numbers

The thresholds published by Google: LCP good at 2.5s or under, INP good at 200ms or under, CLS good at 0.1 or under, each judged at p75. Treat them as a floor, not a target. And keep expectations calibrated: field data moves slowly (28-day window), varies with traffic mix, and can shift because your marketing changed, not because your code did. Before celebrating or panicking over a jump, check whether the traffic composition moved first.

The discipline is unglamorous: measure in the field, diagnose in the lab, fix the specific attributed cause, guard against regression in CI, then wait for the field number to confirm. Every step teams skip is a step where effort goes into something users never feel.