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.

web-vitals lifecycle: register once, accumulate, flush one beacon At startup each metric callback is registered once. Callbacks fire over the page lifetime and update a map keyed by metric.id. On pagehide a single beacon carries the final values. Startup (once) onLCP onINP onCLS onFCP onTTFB Callbacks fire value + delta + id over page lifetime buffer Map keyed by metric.id latest write wins per id no duplicates pagehide: one sendBeacon all metrics, single request bfcache restore new id, new page load
Register each metric once, accumulate by metric.id, and flush a single beacon on pagehide. See the beacon collection endpoint guide for the receiving side.

Prerequisites

  • web-vitals v4 installed and pinned (npm install web-vitals@4 then verify the lockfile). v4 changed onFID out 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 POST of application/json (or text/plain for sendBeacon). 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, and navigationType.

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 report with console.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 for LCP on 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 metrics array with one object per metric name, each carrying value, delta, and id. Confirm INP is present (v4) rather than FID.
  • 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 onLCP in useEffect and unmounts (route change) leaves the observer attached; remounting registers a second one. Always register from a module-level singleton, guarded by the initialized flag.
  • Reading value too early. LCP and CLS are not final until the page is hidden. Logging metric.value from a default (non-reportAllChanges) callback is safe because it only fires when settled; reading from a captured variable mid-load is not.
  • pagehide not firing. On mobile Safari and during OS-level tab eviction, pagehide is unreliable. The visibilitychangehidden listener is the dependable trigger; keep both.
  • Double flush on the same hide. Without buffer.clear(), a visibilitychange flush followed by a pagehide flush 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, summing value instead of taking the latest, or summing delta correctly, are the only two valid choices — anything else mis-aggregates.
  • Beacon size and content type. sendBeacon posts as text/plain unless you wrap the body in a Blob with 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.