Tutorial · performance · Published 2026-08-16 · 3 min read
Measuring Core Web Vitals with the web-vitals JavaScript library
Using the web-vitals JavaScript library to measure LCP, INP and CLS, send field data to analytics and debug the metrics in a real browser.
Why measure on real users
The Core Web Vitals that matter for ranking come from field data: what real visitors experienced, not what a lab machine reported. The official web-vitals npm package measures those same metrics in the browser and hands you the values to send wherever you keep analytics. It reports LCP, INP and CLS exactly as Chrome measures them, including the buffering rules that capture the final CLS even when it happens after the page is visible.
Add the library
Install the package and load it at the top of the page body:
npm install web-vitals
import { onLCP, onINP, onCLS } from 'web-vitals';
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
The <head> matters: the earlier the script runs, the more likely the callback captures the start time for LCP and the interactivity window for INP. Load the script as type="module" and do not defer it, because INP and CLS buffers only begin with the first script run.
Send to analytics
function sendToAnalytics(metric) {
const body = {
name: metric.name,
value: metric.value,
id: metric.id,
delta: metric.delta,
rating: metric.rating,
};
navigator.sendBeacon('/analytics/web-vitals', JSON.stringify(body));
}
The web-vitals package exposes a reportAllChanges flag on the metric options; leave it disabled (default) or the CLS counter fires on every frame of layout shift instead of the final value. A beacon endpoint can be any small server route that your analytics or logging pipeline consumes.
Send the metrics, not just a number
Field data without segmentation is a number with no owner. Send at least:
- The
idfrom the metric, which identifies the unique page-load, so your backend can deduplicate and you can match it against the URL. url,device type, and the page path to separate the 95th percentile.- Timestamps so you can line the field value up against a release or an experiment.
A config report that sends name/value/delta to your BI renders a graph; the same payload sent without id collapses every load into one row and loses the ability to see the 75th-percentile story the report wants.
The four traps of measuring in prod
- LCP on a cached page. If the LCP candidate is served from memory cache, the browser reports a value of 0 or a minuscule number. Filter
delta/valueat the endpoint forvalue > 0before averaging. - CLS ignores the above-buffer. A CLS after
firsthiddenis not counted; the library handles that correctly, but a lab tool that recalculates CLS from a screenshot can over-report. Trust the library over your bug-finder. - Bots and localhost. Exclude crawlers and your own team by user-agent and by
navigator.webdriver, or your 95th percentile turns into a mix of bots and the staging box. - Double reporting. If a tag manager injects the script into a SPA, each route may re-run the callbacks. The
idguard dedupes.
Verify the wiring
In DevTools, block your beacon endpoint, then load the site and check the console: the library logs to console.debug when its debug option is enabled at metric registration, showing the computed metric values. A real user's page visit must then produce a row in your analytics, not an empty report.
The three metrics are explained in the Core Web Vitals reference, the audit procedure walks the debug order when the numbers look wrong, and the PageSpeed article pairs field data with the lab run that reproduces a single page view.