Using the web-vitals npm Library Correctly
The web-vitals package looks deceptively simple: import onLCP, pass a callback, ship it. In production that naive wiring produces duplicate beacons, metrics read before they finalize, values that never arrive because the tab was closed, and double initialization when a framework re-runs your setup module. This page walks the exact, correct usage of web-vitals v4 for a single full page load — registering each metric callback once at startup, understanding reportAllChanges, reading delta versus value, batching every metric into one beacon on visibilitychange/pagehide, and deduping by metric.id. It extends the patterns in the Web Vitals API Implementation reference, which covers PerformanceObserver plumbing and buffered entries underneath the library.
metric.id, and flush a single beacon on pagehide. See the beacon collection endpoint guide for the receiving side.Prerequisites
web-vitalsv4 installed and pinned (npm install web-vitals@4then verify the lockfile). v4 changedonFIDout for INP as the responsiveness metric, so older v2/v3 wiring is wrong.- A single module that runs once per page load — typically your app entry point, not a React/Vue component that mounts and unmounts.
- An ingestion endpoint that accepts
POSTofapplication/json(ortext/plainforsendBeacon). The receiving side is covered in Self-Hosted Beacon Collection. - A mental model of the metric fields: every metric object exposes
name,value(the current best estimate),delta(the change since the last callback),id(a unique-per-page-load string),rating, andnavigationType.
How to use web-vitals v4 correctly
1. Register each metric callback exactly once at startup
The single most common mistake is registering inside a component body. Each call to onLCP(cb) attaches its own PerformanceObserver; calling it on every render or remount leaks observers and double-counts. Register once, at module top level, in code that the framework imports a single time.
// vitals.js — imported once from your app entry, never from a component
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
let initialized = false;
export function initVitals(report) {
if (initialized) return; // guard against double init (HMR, double-import)
initialized = true;
onTTFB(report);
onFCP(report);
onLCP(report);
onCLS(report);
onINP(report);
}
Why: the initialized guard makes the module idempotent. Hot-module reloading, route-level code that re-imports the entry, and React 18 Strict Mode double-invocation all try to run setup twice; without the guard you get two observers per metric and beacons that report each value twice. LCP and CLS are especially sensitive because they keep updating until the page is hidden — a duplicate observer doubles the noise on exactly the metrics that matter most for ranking.
2. Decide reportAllChanges vs the single final report
By default each on* function calls your callback once, with the final value, when the metric is settled (LCP/CLS finalize when the page is backgrounded or unloaded; FCP/TTFB resolve early; INP finalizes at page hide). Passing { reportAllChanges: true } instead fires the callback on every change.
| Mode | Callback fires | Use when |
|---|---|---|
Default (reportAllChanges omitted) |
Once, final value at finalize | Production RUM — you only need the value Google would record |
{ reportAllChanges: true } |
On every interim update | Debugging in DevTools; live overlays; never for raw beacon volume |
// Production: take the final value only.
onLCP(report);
// Debug overlay: watch every interim update in the console.
onLCP((m) => console.log('LCP now', Math.round(m.value), 'delta', Math.round(m.delta)),
{ reportAllChanges: true });
Why: reportAllChanges multiplies callback invocations. If you naively send a beacon per callback you flood your endpoint with intermediate LCP candidates and partial CLS sums. Use the default for collection; reserve reportAllChanges for local debugging or an in-page HUD.
3. Read delta vs value correctly
value is the current best estimate of the metric. delta is the amount value changed since the previous time this metric’s callback ran. The two are equal on the first callback. The rule: report value if you store one row per metric per page; report delta if your backend sums increments. For CLS, summing delta across all callbacks reconstructs the final value exactly — which is how you stream a metric that grows over time without sending the full running total each tick.
function report(metric) {
// Single-row-per-page model: keep the latest value, ignore the running sum of deltas.
buffer.set(metric.id, {
name: metric.name,
value: metric.value, // best estimate; NOT a partial delta
delta: metric.delta, // included so a delta-summing backend also works
id: metric.id,
rating: metric.rating,
navigationType: metric.navigationType,
});
}
Why: mixing the two corrupts aggregates. If you SUM(value) in a delta-streaming pipeline you over-count; if you SUM(delta) while only sending final values you under-count. Pick one model and keep value and delta both on the wire so the backend is unambiguous.
4. Buffer every metric keyed by metric.id and flush one beacon
Don’t send a request per metric — that’s five round trips and several will race the page unload. Accumulate into a Map keyed by metric.id, then flush the whole map in a single beacon. Keying by id is what dedupes: if the same metric reports again (interim updates, or the same id re-delivered), the latest write replaces the earlier one instead of producing a second row.
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
const ENDPOINT = '/rum';
const buffer = new Map();
let initialized = false;
function report(metric) {
buffer.set(metric.id, { // dedupe by id: latest write wins
name: metric.name,
value: metric.value,
delta: metric.delta,
id: metric.id,
rating: metric.rating,
navigationType: metric.navigationType,
});
}
function flush() {
if (buffer.size === 0) return;
const body = JSON.stringify({
url: location.pathname,
metrics: [...buffer.values()],
});
buffer.clear(); // avoid re-sending the same metrics twice
if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, body)) return;
fetch(ENDPOINT, { method: 'POST', body, keepalive: true,
headers: { 'Content-Type': 'application/json' } });
}
export function initVitals() {
if (initialized) return;
initialized = true;
onTTFB(report); onFCP(report); onLCP(report); onCLS(report); onINP(report);
// Flush exactly when the page is being put away. pagehide covers unload AND bfcache;
// visibilitychange->hidden covers tab-switch on mobile where pagehide may not fire.
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush);
}
Why: navigator.sendBeacon is the only delivery method the browser guarantees during unload. One beacon with all metrics is cheaper, atomic on the receiving side, and avoids the partially-delivered-page-load gaps you get from per-metric fetch. Clearing the buffer after flush means a second visibilitychange (tab switched back and away again) won’t resend already-delivered rows.
5. Handle the bfcache restore
When the back/forward cache restores a page, no fresh page load occurs, so web-vitals does not re-run TTFB/FCP/LCP. The library treats a bfcache restore as a new “navigation” and emits fresh metric objects with new ids. Your code needs nothing special beyond keying by id and flushing on pagehide — but you must never assume a flush is final. A flushed page can come back.
addEventListener('pageshow', (event) => {
if (event.persisted) {
// Restored from bfcache. web-vitals already emits new metric ids;
// the buffer is empty (we cleared it on hide), so we simply keep collecting.
console.debug('[vitals] bfcache restore — collecting under new metric ids');
}
});
Why: treating pagehide as a permanent teardown breaks bfcache. Because we clear the buffer on flush rather than removing listeners or disconnecting observers, the restored page continues to accumulate into a clean buffer and flushes again on its next hide.
Verifying it works
- DevTools Console: temporarily wrap
reportwithconsole.log(metric.name, metric.value, metric.id). Confirm you see exactly one final entry per metric for a single load, and that ids are unique. Two entries with different ids forLCPon one load means double init. - Network panel: filter by your endpoint. You should see a single request fire as you switch tabs or close the page (look in the “ping”/beacon category). If you see five separate requests, you’re still sending per metric.
- Request payload: inspect the beacon body. It should contain a
metricsarray with one object per metric name, each carryingvalue,delta, andid. Confirm INP is present (v4) rather thanFID. - RUM dashboard: the p75 of each metric should be stable across deploys with no sudden doubling. A step-change doubling of beacon volume after a release is the classic double-init signature.
- Official panel cross-check: install the Web Vitals Chrome extension and compare its console-logged LCP/CLS/INP to your beacon values for the same page view — they should match within rounding.
Edge cases & gotchas
- Registering in a component that unmounts. A React component that calls
onLCPinuseEffectand unmounts (route change) leaves the observer attached; remounting registers a second one. Always register from a module-level singleton, guarded by theinitializedflag. - Reading
valuetoo early. LCP and CLS are not final until the page is hidden. Loggingmetric.valuefrom a default (non-reportAllChanges) callback is safe because it only fires when settled; reading from a captured variable mid-load is not. pagehidenot firing. On mobile Safari and during OS-level tab eviction,pagehideis unreliable. Thevisibilitychange→hiddenlistener is the dependable trigger; keep both.- Double flush on the same hide. Without
buffer.clear(), avisibilitychangeflush followed by apagehideflush sends the same rows twice. Clearing after each flush makes the second flush a no-op. - CLS under
reportAllChanges. If you enable it and forget that CLS callbacks fire repeatedly, summingvalueinstead of taking the latest, or summingdeltacorrectly, are the only two valid choices — anything else mis-aggregates. - Beacon size and content type.
sendBeaconposts astext/plainunless you wrap the body in aBlobwith an explicit type; many endpoints reject the implicit type. Keep payloads well under the ~64 KB beacon ceiling by sending values, not full attribution objects.
FAQ
Should I use reportAllChanges in production?
No. Use the default single final report for collection. reportAllChanges: true fires the callback on every interim update, which is useful for a debugging overlay but multiplies beacon volume and sends intermediate, non-final values.
Do I report metric.value or metric.delta?
Report metric.value if your backend stores one row per metric per page load. Report and sum metric.delta if your backend reconstructs the total from increments. Send both fields so the storage model is unambiguous, but never mix the two when aggregating.
Where do I register the web-vitals callbacks?
In a module that runs exactly once per page load — your app entry point — guarded by an initialized flag. Never inside a component that can mount and unmount, because each registration attaches a new PerformanceObserver and double-counts metrics.
Related
- Web Vitals API Implementation — the parent reference covering PerformanceObserver, buffered entries, and the library internals.
- Debugging Web Vitals with the Attribution Build — swap to
web-vitals/attributionto find the element and timing behind a bad metric. - Self-Hosted Beacon Collection — the ingestion endpoint that receives the single batched beacon.