Skip to content
CharliezServices

Engineering

Core Web Vitals Field Data Beats Your Lighthouse Score

7 minute read

A green PageSpeed score reflects one simulated load on a fast machine. Real users arrive on mid-range phones over congested networks, and that is the population Google actually scores you against.

You ship the performance work. PageSpeed Insights turns green across the board, someone screenshots it for the client, and the ticket closes. Six weeks later Search Console still lists the same URLs under Needs Improvement, and people still say the site feels slow on their phone.

Nothing was faked. The green number and the failing number are measuring two different things, and only one of them is the number Google stores against your URLs.

A Lighthouse run is one simulated load on a machine that is not your user's

Lighthouse loads the page once, with an empty cache, no cookies, no logged-in session, no extensions, and no interaction with your consent banner, from whichever machine or data center happens to be running it. Mobile runs use simulated throttling: the page is fetched at full speed and the timings are then adjusted mathematically to estimate a slow connection and a 4x slower CPU. It is a model of a cheap phone, not a cheap phone.

Two things follow. Run-to-run variance on an unchanged page is routinely a few hundred milliseconds, so a two point score change means nothing. And the performance score is a weighted composite: First Contentful Paint at 10 percent, Largest Contentful Paint at 25, Total Blocking Time at 30, Cumulative Layout Shift at 25, Speed Index at 10.

Notice what is absent. Interaction to Next Paint is not in the Lighthouse score at all, because INP requires a real person to actually click something. Lighthouse substitutes Total Blocking Time, which correlates loosely and completely misses the case where a page loads with a quiet main thread and then blocks for 400ms the first time someone opens the mobile menu.

The score that counts is 28 days of real Chrome users at the 75th percentile

The Chrome User Experience Report aggregates timings from real Chrome users who meet its eligibility conditions, over a rolling 28 day window, reported per origin and, where there is enough traffic, per URL. Every metric is published at the 75th percentile and split by device class.

The rolling window has a practical consequence people miss. On the day after you deploy a fix, one twenty-eighth of the window contains the new code. Full effect takes four weeks. Teams routinely decide a fix did not work while looking at a window that is still mostly the old build.

The 75th percentile is the part that makes averages useless. Take a hundred loads: seventy at 1.2 seconds, twenty-five at 6 seconds, five at 12 seconds. The mean is 2.94 seconds, which reads as acceptable. The 75th percentile is 6 seconds, which is a clear fail. The average is describing your fast users. The percentile is describing the quarter of people most likely to leave, and it is the one that goes into the assessment alongside the rest of your technical SEO.

A lab score is a hypothesis about your users. Field data is what actually happened to them.

LCP usually fails on an element nobody was looking at

Split LCP into its four parts: time to first byte, resource load delay, resource load duration, and element render delay. In real field data the failures cluster in load delay and render delay, not in bandwidth. The file is not too big. The browser found out about it too late, or painted it too late.

  • The hero image is referenced from a CSS background, so the preload scanner cannot see it until the stylesheet has parsed and the rule has matched.
  • loading="lazy" is on the hero, usually applied globally by a CMS or a default in an image component.
  • A webfont uses font-display: block, hiding the LCP heading for up to three seconds before any fallback appears.
  • The element has an entrance animation. An element at opacity 0 has not painted, and LCP is not recorded until it becomes visible, so a 600ms fade costs you 600ms.

There is a subtler trap with decoding="async". It tells the browser it may present a frame without waiting for that image to finish decoding, which is the right default for images below the fold. Put it on the LCP image and you can get a first paint that does not contain the image, with the actual pixels arriving a frame or two later. Verify the LCP element by reading pixels back off a canvas, not by looking at a screenshot, because a screenshot taken after the fact cannot tell you which frame it landed in.

The worst layout shifts happen after the screenshot

The familiar causes are still the common ones: images without width and height or an aspect-ratio, consent banners and promo bars injected at the top of the DOM after first paint, embeds and ads with no reserved slot, and late webfonts whose metrics differ from the fallback. That last one is fixable with size-adjust and ascent-override in the @font-face rule, or a font loader that computes fallback metrics for you.

The one that ruins field data while the lab stays green is build-time prerendering of a single page app that lazy-loads its route components. The prerendered HTML contains the full page. At hydration, React mounts the tree, the lazy chunks are not in memory yet, and the Suspense fallbacks render in place of the prerendered markup. Everything below collapses upward, the chunks arrive, and everything expands again. Two shifts, and a CLS above 1.0 is normal.

Lighthouse frequently misses this because the chunk is served fast enough locally that it resolves inside the same frame. The fix is either to stop lazy-loading anything that appears in the prerendered output, or to emit modulepreload links for those chunks so they are in flight before hydration starts. Either way the change lives in the bundler config, which is why performance work on an application is full stack development rather than a CSS pass.

INP is almost never your event handler

INP is measured in three parts: input delay before your code runs, processing time, and presentation delay before the next frame. Input delay is usually the whole story. Your click handler runs in three milliseconds; the interaction took 500 because the main thread was already occupied.

The occupiers are predictable. A large hydration pass. A tag manager container pulling in four more scripts. A chat widget, a session recorder, a consent platform. None of that appears in your code review, and all of it lands on the same thread that has to respond to a tap.

The Long Animation Frames API is what makes this diagnosable in the field. It reports long frames with attribution: the script URL, the invoker, and how long each stage took. The web-vitals library's attribution build surfaces the same thing for INP, including the interaction target selector.

Collect your own field data rather than waiting out the window

CrUX tells you that you have a problem, four weeks late, with no attribution. Your own RUM tells you today, with a CSS selector attached.

  1. Register a PerformanceObserver for largest-contentful-paint, layout-shift, event with a durationThreshold, and long-animation-frame, all with buffered: true so you catch entries from before your script ran. Or install web-vitals and use onLCP, onCLS and onINP from the attribution build.
  2. Hold the values in memory and send once, on visibilitychange when the state is hidden, using navigator.sendBeacon. Do not use unload. It does not fire reliably on mobile and it disqualifies the page from bfcache.
  3. Store the attribution next to the value: the LCP element selector, the source node of the largest shift, the INP target and the longest script in the blocking frame. A number without a selector is not actionable.

Be honest about coverage. LCP, layout shift and event timing are Chromium only. Safari gives you paint and navigation timing and little else, so your own data is not the whole audience either. It is same-day, it is per-route, and it is yours, which beats a monthly aggregate you cannot query.

Segment by device class or the mobile failure stays invisible

Record navigator.deviceMemory, hardwareConcurrency, connection.effectiveType, viewport width and the route template with every beacon. Then look at the 75th percentile per segment rather than in aggregate. Desktop will pass. A four year old Android on a congested network will not, and it is often a large share of the traffic that matters commercially.

The same applies to URLs. Low-traffic pages have no URL-level CrUX data at all and inherit the origin assessment, so a fast homepage carrying most of the traffic can drag a slow product template over the line at origin level while every individual visit to that template is bad.

Use the lab for what it is good at: a pre-merge regression gate with hard budgets on a fixed runner, so nobody ships a 900KB dependency by accident. Use field data to decide what to work on and to judge whether the work did anything. When the two disagree, the field is right, and the fix usually belongs in the build and rendering layer rather than in another round of image compression.

Related service

Want this handled for you?

Technical repair, content matched to real search intent, and reporting that ties rankings to pipeline instead of to a vanity chart.