Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 03 / 09 30 MIN

Measuring first

Lab Data Versus Field Data

Synthetic tools measure one simulated load on one machine. Field data measures your users. Learn what each can answer, and why the two numbers rarely match.

You run Lighthouse, get a 96, and ship. A month later the Core Web Vitals report in Search Console says most of your URLs need improvement, and a colleague sends a screenshot of one of them. Both numbers came from something real. They measure different populations, and until you know which population each one covers, the disagreement looks like a contradiction instead of a clue.

Everything in the last two lessons was lab data. You chose the URL, the cache state, the dataset and the moment, and you ran one request at a time on a machine you control. That is what makes the numbers repeatable and what makes them blind to your visitors.

A synthetic run measures one load under conditions you set

Lighthouse, and the lab half of PageSpeed Insights, load the page once in a controlled browser with no cache, no extensions and an empty profile, then score what happened. The throttling is simulated: the tool estimates how a slow connection and a slow CPU would have changed the timings rather than shaping real traffic. That makes it cheap and consistent, and it is why two people running Lighthouse on the same URL usually get numbers within a few points of each other.

What a synthetic run can answer, it answers well. Which resource blocked the first paint. How large the largest image is. Whether the layout moved because a font swapped or because an ad slot reserved no space. Those are mechanism questions, and the lab is the right instrument for all of them.

What it cannot answer is whether any of it matters to your visitors. One load, on one machine, on one connection profile, with one version of the page, is the same anecdote problem from lesson 1, just with more colours. A lab score of 96 is a statement about a hypothetical visit that took place on a laptop in a data centre with a warm DNS cache.

The lab reaches the server too

The warm-cache benchmark from lesson 1 is also lab data, and so is a query log. Choosing the URL, the dataset and the cache state makes them repeatable and makes them blind to your traffic mix. The lab-versus-field distinction is not about the browser.

Field data is measured by real browsers belonging to real people

Field data comes from instrumentation running in the visitor's browser. The mechanism is the same in all three sources below: the browser exposes performance entries, a script collects the ones that matter, and the results are aggregated across many visits.

You write that script yourself for your own field data. Google's web-vitals library is the maintained way to do it, and it exposes one function per metric. Loaded as a module and reporting into sendBeacon, it is about twenty lines:

import { onLCP, onINP, onCLS } from 'https://unpkg.com/web-vitals@5?module';

function report(metric) {
    const body = JSON.stringify({
        name: metric.name,
        value: metric.value,
        rating: metric.rating,
        id: metric.id,
    });
    navigator.sendBeacon('/analytics/vitals', body);
}

onLCP(report);
onINP(report);
onCLS(report);

Three details matter before you deploy that. The library uses PerformanceObserver with the buffered flag, so it picks up entries recorded before it loaded and you do not have to block rendering to beat the metrics to the page. Some callbacks fire more than once, which is why metric.id exists: group by it and keep the last value, or sum the delta values. And INP is not reported at all if the visitor never interacts, which is a real gap in a dataset made mostly of bounce traffic.

The library measures what the browser APIs expose, which has a documented blind spot: it has no visibility into iframe content, not even same-origin iframes. The Chrome UX Report does include iframe content. If your site embeds anything substantial, your own RUM numbers and CrUX numbers are measuring slightly different pages, and that alone can explain a gap between them.

CrUX is the field dataset you do not have to build

The Chrome UX Report is Google's aggregate of real Chrome visits, divided into origins and URLs. You get it in four shapes, and they differ in ways that matter:

SourceFreshnessScopeWhat it is good for
CrUX API 28-day rolling average, updated daily Origin and URL One number for one URL right now, from a script or a dashboard.
CrUX History API Weekly, 40 collection periods Origin and URL Whether a change you shipped three weeks ago moved anything.
CrUX on BigQuery Monthly Origin only Filtering by country, form factor and connection type across many origins.
Search Console 28-day average, three months of history Page groups Which sections of the site are failing, split by mobile and desktop.

URL-level data only exists for URLs that clear a traffic threshold in the collection period. That is why a CrUX query for a specific page sometimes returns nothing while the origin returns plenty, and it is not an error you can fix by changing the request. Ask for the origin, and use Search Console's page groups to drill into sections.

Search Console groups URLs that it judges to give similar experiences and reports Core Web Vitals aggregated per group, with up to twenty sample URLs per group. The practical consequence is that the report tells you "this section is failing" more reliably than it tells you "this page is failing".

The three Core Web Vitals and the thresholds they are judged against

Core Web Vitals is currently three metrics. They are reported at the 75th percentile, which means the number you see is the value that three quarters of visits beat. That is deliberate: it describes a bad visit rather than an average one, in the same spirit as the p95 in lesson 1.

MetricGoodNeeds improvementPoor
Largest Contentful Paintup to 2.5 s2.5 s to 4 sover 4 s
Interaction to Next Paintup to 200 ms200 ms to 500 msover 500 ms
Cumulative Layout Shiftup to 0.10.1 to 0.25over 0.25

INP replaced First Input Delay as a Core Web Vital, and the difference between them explains a lot of advice that has aged badly. FID measured only the delay before the browser started handling the first input, so a handler that blocked the main thread for a second still scored well. INP measures the whole interaction, from the input to the frame that finally paints, and takes the worst interaction of the visit rather than the first. If you are still optimising FID, you are optimising a metric that is no longer reported.

Two field-measurement limits follow from how these are defined. CLS comes from the Layout Instability API, which is currently only available in Chromium, so a large Safari or Firefox population is invisible to CLS reports. And LCP and INP are not reported for pages loaded in the background.

Why the two numbers disagree, and what to do first

Assume the lab says 1.8 s for LCP and CrUX says 3.4 s at the 75th percentile, which is a needs-improvement. Both are correct, and there is a short list of reasons.

The population differs. CrUX includes phones on cellular connections in the countries you actually serve. Your lab run was a desktop profile on a fast connection, or a phone profile applied to a desktop machine. The 75th percentile also describes the slow quarter of visits, not the middle, so it sits above the median by construction.

Your own field data and CrUX differ too. Different sampling (only Chrome for CrUX, whatever browsers you instrument), different page version (CrUX may still be reporting visits from before your deploy, because it is a 28-day window), and the iframe blind spot. Ignoring a CrUX number because "our RUM says it is fine" is usually a mistake: CrUX is closer to the population that Google's page experience signals are computed from.

The lab is a single cold load, the field is mostly warm ones. This cuts both ways and is the reason a lab score can be better or worse than the field. A cold load pays to build caches; a returning visitor does not. But a returning visitor also has extensions, a slower device and a busier main thread.

Here is the same page measured with the instrumentation above, on a local server, in headless Chrome:

LCP value=36 rating=good target=#hero ttfb=3 renderDelay=33
INP value=184 rating=good target=#busy type=pointer
    inputDelay=0.2 processing=180.3 presentation=3.5
CLS value=0 rating=good largestShift=undefined

Read the INP line as an argument for attribution. The value is 184 ms, which is good and close to the 200 ms boundary, and almost all of it is processingDuration. The input delay is a fifth of a millisecond, so the main thread was idle when the click arrived, and the handler itself is the cost. That is actionable: make the handler cheaper. If the same 184 ms had been nearly all inputDelay, no amount of handler work would have helped. A field number without attribution tells you that you have a problem. Attribution tells you where it lives.

CLS is 0 and stays 0, because the page reserves space for everything it renders. That is what a good CLS looks like when it is a measurement rather than a hope. Add one element above the fold without dimensions and the same instrumentation will say so on the next load, which is a cheaper experiment than arguing about it.

Use each source for the question it can answer

QuestionSourceTime to an answer
Which resource delayed the largest paint on this page? Synthetic run, or your own lab Minutes
Did the layout move because the hero image has no dimensions? Synthetic run with layout shift debugging Minutes
Is the interaction cost in the handler or in the main thread before it? Attribution from your own RUM Hours, once collection is running
Which device class and country is dragging the 75th percentile down? CrUX on BigQuery, or your own RUM dimensions Hours for RUM, a day or more for a BigQuery build
Is this section of the site failing for real visitors? Search Console Core Web Vitals report Days, because it lags your deploy
Did last month's release make things better? CrUX History API, quartered by deploy date Weeks
Does this URL have enough traffic for URL-level data? A CrUX API query for that URL Minutes

The order of use matters more than the numbers. Read the field first, because it tells you whether there is a problem and roughly which pages carry it. Then reproduce it in the lab, where you can change one thing at a time. Then ship, and go back to the field to find out whether the change survived contact with actual visitors. The lab is where you take things apart. The field is where you find out if it mattered.

The thing this approach will not do is tell you the cause of a field problem you cannot reproduce. Some slow visits are slow because of an extension, a captive portal or a device that is out of memory. For that you need attribution data from the field, and even then the honest answer is sometimes that a small fraction of visits are slow for reasons you do not control.

Lab 30 minutes

Add Core Web Vitals reporting to a staging page using the module import above, with a collector endpoint that stores name, value, rating, id and attribution in a table. Then collect at least fifty samples from a phone on cellular, a phone on wifi and a desktop, and compute the 75th percentile for each metric.

Now run a synthetic test against the same URL and compare the two. Answer these: which metric disagrees most between your lab run and your field sample, and why? Does your sample have enough non-bouncing visitors for INP to be meaningful? If you looked up the URL in the CrUX API, would you expect a URL-level record to exist, and what tells you either way?

Show the solution and the reasoning

The collector has one job that is easy to get wrong: accept the beacon and store the raw metric object without trying to interpret it at request time. Sums and percentiles are a query over the table later, not work for the ingest path, because the ingest path runs on every page view for every visitor.

// One build or the other, not both on the same page.
import { onLCP, onINP, onCLS } from 'https://unpkg.com/web-vitals@5/attribution?module';

function collect(metric) {
    navigator.sendBeacon('/analytics/vitals', JSON.stringify({
        name: metric.name,
        value: metric.value,
        rating: metric.rating,
        id: metric.id,
        delta: metric.delta,
        navigationType: metric.navigationType,
        attribution: metric.attribution || null,
    }));
}

onLCP(collect);
onINP(collect);
onCLS(collect);

Import the attribution build only if you are going to use the extra fields, because it is larger than the standard build and it runs on the page you are measuring. The fields that pay for themselves are target on both LCP and INP, timeToFirstByte and elementRenderDelay on LCP, and the three INP subparts. Those turn a number into a location.

On the sample above, the lab run and the field sample agreed on rating and disagreed on value, which is the common case. The page scored good on all three in the lab: LCP 36 ms, INP 184 ms, CLS 0. Those numbers come from a local server with no network between the browser and the response, a desktop CPU, and a page with one image-free hero block, so they are a floor rather than a forecast. A phone on cellular cannot be expected to reproduce any of them, and the gap is not evidence that the lab run was wrong. It is evidence that the lab run answered a different question.

Which metric disagrees most depends on the page, but the ranking is predictable when a page has an image above the fold. LCP moves most, because it includes the time to fetch a resource over a real connection, and the lab's connection is not real. CLS and INP move less, because they are determined by layout and by main thread work, which a faster connection does not fix. A page whose lab INP is 180 ms and whose field INP is 400 ms has a device problem, not a network problem, and the attribution will say so.

The INP sample size is the trap. INP is only reported after an interaction, so visitors who land, read and leave contribute nothing. Fifty samples from a page with a thirty percent interaction rate is around fifteen INP values, which is not enough to talk about a 75th percentile. Either collect for longer or add durationThreshold handling and be explicit in your dashboard that the denominator is interactions, not page views.

On whether a CrUX URL-level record exists: query the CrUX API for the URL and read the response rather than guessing. A URL that returns no record has too little Chrome traffic in the 28-day window, and no header or parameter changes that. Fall back to the origin-level record, and treat the Search Console page group as the middle ground between "this page" and "this whole site".

Progress

Mark this lesson done and it stays marked in this browser.