Lighthouse says 100, but Search Console says "needs improvement" — same page, so what gives? The answer lives in what Core Web Vitals actually measure and where. Google's three user-experience metrics — LCP, CLS, and INP — are measured one way in the lab (Lighthouse) and another in the field (real users), and they turn vague feelings like "the page feels slow" into concrete milliseconds and scores. This guide covers what each metric measures, why lab and field diverge, what hurts each, and how to fix them.
What Core Web Vitals are
Google's quantified definition of "good page experience." As of March 2024 the set is three metrics:
Metric good needs improvement poor
─────────────────────────────────────────────────────────────────────
LCP (Largest Contentful Paint) ≤ 2.5s 2.5s – 4.0s > 4.0s
CLS (Cumulative Layout Shift) ≤ 0.1 0.1 – 0.25 > 0.25
INP (Interaction to Next Paint) ≤ 200ms 200ms – 500ms > 500msA page passes Core Web Vitals only when all three land in "good." INP officially replaced FID (First Input Delay) in March 2024 — FID only measured the delay of the first input, while INP looks at the responsiveness of every interaction across the page's lifetime.
LCP — when the largest content paints
The time when the largest content element in the viewport (usually a hero image, a big text block, or a video poster) is painted on screen. A proxy for the moment a user feels "this page has loaded."
navigation start (t=0)
│
├─ TTFB ──── server sends the first byte
│
├─ FCP ───── first pixel (any text/background)
│
└─ LCP ───── largest element painted ← this is the metric
↑ must be ≤ 2.5s for goodWhat hurts LCP:
- Slow TTFB — if the server/CDN sends the first byte late, everything downstream slips. TTFB alone can eat half the LCP budget.
- Render-blocking CSS / JS — synchronous stylesheets and scripts in
<head>block the first paint. - Late-discovered hero image — images set via CSS
background-imageor injected by JS aren't found early by the browser's preload scanner, so the download starts late. - Lazy-loading the LCP element — putting
loading="lazy"on a hero image defers it even though it's in the viewport, wrecking LCP. Lazy is for below-the-fold images only. - Unoptimized images — a 1 MB PNG hero is slow on its own. Use WebP/AVIF at the right dimensions.
CLS — unexpected layout shifts
The cumulative amount by which already-visible elements jump around while the page loads. Score = (fraction of viewport affected) × (fraction of distance moved), summed over every unexpected shift. Closer to 0 is more stable.
Bad CLS scenario:
1. Text paints first
2. An image (no dimensions) arrives late above it
3. The text "jumps" down ← the button you were about to tap runs away
Good CLS:
1. Reserve the image's box up front (width/height or aspect-ratio)
2. Image arrives → fills the reserved space, nothing movesWhat hurts CLS:
- Images / videos / iframes without dimensions — the browser can't reserve space, so they shove content when they arrive.
- Ad / embed slots — dynamically filled ads and widgets push content down if no space was reserved.
- Web-font swap — text paints in a fallback font, then reflows when the web font arrives and glyph widths change (FOUT).
- Content injected above existing content — banners, cookie notices, and "read this first" messages inserted at the top of the DOM push everything below them down.
Note — shifts within 500 ms of a user interaction count as "expected" and are excluded. An accordion that expands when you click it doesn't hurt CLS.
INP — responsiveness to interactions
When a user clicks, taps, or types, the delay until the next frame is painted. INP reports (roughly) the slowest such interaction over the page's lifetime as a representative value. It quantifies the "I pressed it and it stuttered" feeling.
user click
│
├─ input delay ──── main thread busy with a long task, input waits
│
├─ processing ───── the event handler runs (longer if heavy)
│
└─ presentation ─── until the next paint (rendering the DOM change)
↑ total must be ≤ 200ms for goodWhat hurts INP:
- Long tasks — JS work that holds the main thread for 50 ms+. Any input that arrives meanwhile queues up behind it.
- Heavy event handlers — doing a big synchronous computation or bulk DOM mutation on a single click stretches the processing time.
- Large DOM — thousands of nodes make style/layout recalculation expensive, dragging out the presentation step.
- Excessive JS — hydration and third-party scripts that keep grabbing the main thread make every interaction slow.
Field data vs lab data — why scores diverge
The core reason the same page passes in Lighthouse but fails in Search Console. The two come from different sources.
Lab (Lighthouse) Field (CrUX)
─────────────────────────────────────────────────────────────────
Source one run on your machine/CI collected from real Chrome users
Environment controlled throttling all over — old phones, slow nets
INP not measured directly* measured from real clicks/taps
Aggregation single snapshot 28 days / 75th percentile
Ranking use no (diagnostics only) yes (page-experience signal)* Lighthouse can't measure INP directly — it needs real user interactions. The lab uses proxy metrics like TBT (Total Blocking Time) instead.
CrUX (Chrome User Experience Report) collects data from real Chrome users on a 28-day rolling window. The reported value is the 75th percentile — i.e. "75% of users experienced it at least this good." Because it's p75 and not the mean, the slow tail (old phones, slow networks) drives the score.
So the common traps:
- Your MacBook + fiber = perfect lab score. But the field p75 is a mid-range Android on mobile data → LCP of 4 s.
- Field data is a 28-day rolling window → a fix you just shipped doesn't show up instantly. Expect days to weeks before the score moves.
- Low-traffic pages may be grouped at the origin level in CrUX, or have no data at all.
Bottom line — lab is for fast iteration and debugging; field is the real ranking and real-user verdict. Watch both.
How to measure
1. PageSpeed Insights
Paste a URL and it shows lab (Lighthouse) and field (CrUX) on one screen. The fastest starting point. The "real-user data" section at the top is field; the diagnostics below are lab.
2. The web-vitals JS library
Measure your actual visitors directly via RUM (Real User Monitoring) and ship the numbers to your analytics endpoint. Google's recommended way to collect field data:
import {onLCP, onCLS, onINP} from "web-vitals";
function send(metric) {
// send to your analytics server (beacon survives page unload)
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // "good" | "needs-improvement" | "poor"
id: metric.id,
});
navigator.sendBeacon("/analytics", body);
}
onLCP(send);
onCLS(send);
onINP(send);Collect this and you see your own real-user distribution without CrUX's 28-day lag.
3. Search Console — Core Web Vitals report
Groups CrUX field data by URL pattern and buckets it into "good / needs improvement / poor." See at a glance which URL patterns fail across the whole site. This is the exact data used for ranking.
4. Chrome DevTools — Performance panel
Record a session and you see the LCP marker, layout-shift regions, and long tasks (flagged with red triangles) right in the timeline. Use it to pin down which task holds the main thread for how many milliseconds and which element shifted.
Fixing each metric
Fixing LCP
- Preload the hero image and give it
fetchpriority="high"so the browser fetches it first:
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">
<!-- or on the img itself -->
<img src="/hero.avif" fetchpriority="high" alt="...">- Never put
loading="lazy"on the LCP image. - Minimize render-blocking CSS — inline critical CSS, load the rest asynchronously.
- Cut TTFB — CDN edge cache, server-response caching, static generation.
- Optimize image format and size (WebP/AVIF, responsive
srcset). The image bytes are usually most of LCP.
What the browser discovers and fetches first is covered in more depth in how-browsers-render-pages and how-resource-hints-work. Shrinking the image bytes themselves is the subject of the image-optimization guide.
Fixing CLS
- Set
widthandheightattributes on every<img>/<video>so the browser reserves space from the aspect ratio. CSSaspect-ratioworks too:
<!-- browser reserves a 16:9 box ahead of time -->
<img src="/photo.jpg" width="1600" height="900" alt="...">
/* CSS approach */
.thumb { aspect-ratio: 16 / 9; width: 100%; }- Reserve space for ad/embed slots with a
min-heightso nothing shifts when they fill. - Tame web fonts with
font-display: optional(orswap) + preload + metric overrides (size-adjust) to suppress swap reflow. Details in the how-web-fonts-load guide. - Inject dynamic content below existing content, or reserve the insertion box in advance.
Fixing INP
- Break up long tasks — split work over 50 ms into chunks and yield to the main thread between them:
// chunk a heavy loop and yield so input can sneak in
async function processChunks(items) {
for (const item of items) {
doWork(item);
// modern browsers: scheduler.yield(), fallback: setTimeout
if (window.scheduler && scheduler.yield) {
await scheduler.yield();
} else {
await new Promise((r) => setTimeout(r, 0));
}
}
}- Defer heavy computation in event handlers to
requestIdleCallbackor a web worker — paint the visual feedback (the click response) first, do the heavy work later. - Shrink the DOM — virtual scrolling and pagination keep node counts under control.
- Cut the JS payload — drop unused third-party scripts, code-split, narrow the hydration scope.
Why it matters
Core Web Vitals are part of Google's page experience ranking signal. They act as a tie-breaker when otherwise-equal content competes — not a dominant ranking factor, but failing them is a handicap and passing them is table-stakes hygiene. On top of that, LCP, CLS, and INP are UX metrics that correlate directly with real bounce and conversion, so they're worth fixing regardless of ranking.
References
- web.dev — Core Web Vitals — web.dev/articles/vitals
- INP replaces FID — web.dev/blog/inp-cwv-launch
- web-vitals JS library — github.com/GoogleChrome/web-vitals
- PageSpeed Insights — pagespeed.web.dev
Summary
- Core Web Vitals = LCP (≤2.5s), CLS (≤0.1), INP (≤200ms). All three must be good to pass.
- INP replaced FID in March 2024 — it tracks responsiveness across every interaction, not just the first input.
- Lab (Lighthouse) is one controlled run for diagnostics; field (CrUX) is the 28-day p75 of real users used for ranking. Passing in lab doesn't mean passing in field.
- LCP hurts from slow TTFB, render-blocking, late-discovered hero, and lazy-loading the LCP element. Fix with preload + fetchpriority=high; never lazy-load it.
- CLS hurts from dimensionless media/ads, font swap, and top injection. Fix with width/height, aspect-ratio, reserved slots, and font-display.
- INP hurts from long tasks, heavy handlers, large DOM, and excessive JS. Fix by chunking tasks and yielding, using workers, and shipping less JS.
- Measure with PageSpeed Insights, the web-vitals library (RUM), the Search Console report, and the DevTools Performance panel.