Measuring Core Web Vitals in the Field with the web-vitals Library
Lighthouse cannot measure INP. How to collect LCP, INP and CLS from real users, and why attribution is what matters.
Table of contents
Lighthouse gives you a score in a controlled room with a simulated network and nobody clicking anything. Google assesses your site on what real users on real devices actually experienced.
Those two numbers routinely disagree, and only one of them affects your ranking. The web-vitals library measures the second.
Key takeaways#
- INP cannot be measured in the lab at all — there is no user to interact with the page.
- CLS in Lighthouse covers page load. Field CLS covers the entire page lifetime, including shifts from lazy content and interactions.
- Values must be reported at page hide, not on load — CLS and INP keep changing until the user leaves.
- Use the attribution build. A number tells you there is a problem; attribution tells you which element.
- Assess at the 75th percentile. Averages hide exactly the tail these metrics exist to surface.
Why lab data is not enough#
Lighthouse runs a synthetic load: fixed CPU throttling, a simulated network, no extensions, no interaction, one page view. It is excellent for catching regressions in a pull request, because it is repeatable.
Field data is the opposite — noisy, uncontrolled, and real. It captures the mid-range Android on a poor connection, the user with twelve extensions, and the person who clicked a filter three seconds in.
The gaps are specific:
| Metric | Lab | Field |
|---|---|---|
| LCP | Measurable, one load scenario | Real device and network variety |
| CLS | Load-time shifts only | Whole lifetime, including lazy content |
| INP | Not measurable | The only place it exists |
INP is the decisive one. It measures responses to interactions, and Lighthouse does not interact. A lab tool can report Total Blocking Time, which correlates with INP, but it is a different measurement and pages regularly score well on one and badly on the other.
Installing and reporting#
npm install web-vitalsThe minimum that actually works:
import { onCLS, onINP, onLCP } from 'web-vitals';
function send(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
path: location.pathname,
});
// sendBeacon survives the page being closed. fetch() usually does not.
navigator.sendBeacon?.('/api/vitals', body) ??
fetch('/api/vitals', { body, method: 'POST', keepalive: true });
}
onCLS(send);
onINP(send);
onLCP(send);Two details in there matter more than they look.
sendBeacon rather than fetch. These metrics are finalised as the user leaves, and a normal fetch issued during unload is frequently cancelled. sendBeacon hands the request to the browser to deliver regardless. The keepalive fallback covers browsers without it.
The library decides when to call you. You do not poll. onLCP fires when the largest paint is final, onCLS and onINP when the page is hidden. That is already the correct timing, which is why reporting these on load produces wrong numbers.
Attribution is the part worth adopting#
The default build tells you INP was 480ms. That is not actionable — a page has dozens of interactive elements.
The attribution build tells you which one:
import { onINP, onLCP, onCLS } from 'web-vitals/attribution';
onINP((metric) => {
const a = metric.attribution;
send({
name: metric.name,
value: metric.value,
target: a.interactionTarget, // a CSS selector for the element
type: a.interactionType, // 'pointer' or 'keyboard'
inputDelay: a.inputDelay,
processing: a.processingDuration,
presentation: a.presentationDelay,
});
});Now the report says the filter dropdown took 480ms, of which 390ms was input delay — which points at main-thread contention before the handler, not at the handler itself. That is a different fix from a slow handler, and without attribution you would probably have optimised the wrong thing.
The same applies to the others. onLCP attribution gives you the LCP element and a breakdown into time to first byte, resource load delay, load duration and render delay. onCLS attribution gives you the largest shift source — the element that moved.
The attribution build is larger. Ship it anyway; a smaller bundle that cannot tell you what to fix is a false economy.
Aggregating: use percentiles#
Core Web Vitals are assessed at the 75th percentile of page loads over a 28-day window. Your dashboard should match that.
An average is actively misleading here. A page averaging 90ms INP with a tail past 600ms fails assessment, and the average never hints at it. Report p75 as the number you are accountable for, and p95 alongside it — the distance between them is the size of the tail.
Segment by the dimensions that change the answer:
- Path or route group. One slow template drags a site-wide average and hides where the problem is.
- Device category. Desktop and mobile INP are often entirely different stories.
- Navigation type. A soft navigation in a single-page app performs nothing like a cold load.
CrUX and your own data answer different questions#
The Chrome User Experience Report is the dataset Google actually assesses, available through PageSpeed Insights and Search Console. It is authoritative, and it is coarse: 28-day aggregation, Chrome users only, origin or URL-group level, and no attribution.
Your own web-vitals data is not what you are graded on, but it is immediate, covers every browser that supports the underlying APIs, segments however you like, and identifies elements.
Use CrUX to know whether you have a problem worth attention. Use your own data to find and fix it. Teams that collect only CrUX know they are failing and not why; teams that collect only their own sometimes optimise something Google's dataset never flagged.
A working setup#
- Install
web-vitalsand import fromweb-vitals/attribution. - Report
onLCP,onINPandonCLSthroughsendBeacon. - Include path, device category, and
navigationTypeon every beacon. - Store raw values — percentiles cannot be computed from pre-averaged data.
- Dashboard p75 and p95 per route group, against 2.5s LCP, 200ms INP, 0.1 CLS.
- Check CrUX in Search Console monthly to confirm your data agrees with what Google sees.
Step four is the one to get right at the start. Storing daily averages makes p75 permanently unrecoverable, and that is the number that matters.
For what each metric measures and its thresholds, see the Core Web Vitals guide and the INP 200ms threshold.
Tags
- Performance
- Core Web Vitals
- JavaScript
- Monitoring