mrkeyoor.com_
Thu 06 Aug 02:45 UTC
npmWeb Frontendupdated 06 Aug 2026

web-vitals

web-vitals is Google's own client-side library for measuring the Web Vitals metrics on real users. It exposes one function per metric (onCLS, onINP, onLCP, onFCP, onTTFB), each of which takes a callback that fires when the value is ready to report. The point of the library over reading PerformanceObserver yourself is fidelity: it applies the same session windowing, bfcache handling, prerender adjustments, and edge cases that Chrome uses when it feeds the Chrome User Experience Report, so your numbers line up with what PageSpeed Insights and Search Console show. Each callback receives a metric object with value, delta, rating, a stable id, and the raw performance entries. A second build, web-vitals/attribution, adds a per-metric attribution object naming the element that shifted, the interaction that was slow, or the breakdown of where LCP time went.

Verdict

The reference implementation, maintained by the Chrome team that defines the metrics, and small enough that there is no size argument against it. Budget the real work for the pipeline behind it, and remember that a field library only tells you what already happened to real users.

API stability4/5The onMetric(callback, opts) shape has held since version 3, and upgrades ship with a dedicated docs/upgrading-to-vN.md listing every change. That said, there has been a major every year: version 5 removed onFID, version 6 flipped the INP includeProcessedEventEntries default and requires import type for the types. The churn tracks the metrics themselves changing, but it is churn.
Docs5/5A 1300-line README with full TypeScript interfaces for every metric and attribution object, worked examples for sendBeacon, GA4, Tag Manager, and batching, plus an explicit Limitations section about iframes and a Browser Support list showing that onCLS is Chromium only. Per-version upgrade guides and a changelog with PR links back it up.
Maintenance5/5Pushed the same week as this writing, with 6.1.0 released on 5 August 2026 and 10 open issues. It is maintained by the Chrome team that also defines the metrics, so implementation changes tend to land alongside the browser changes that cause them.
Ecosystem5/5Around 34M weekly downloads and the de facto standard input for Web Vitals data: analytics vendors, Google Tag Manager community templates, and framework integrations wrap it rather than reimplement it. Apache-2.0, zero dependencies, and CDN builds mean it drops into anything, including a plain script tag.

Use it if

  • You want field data from real users rather than lab numbers, and you want it computed the same way Chrome computes it, so your dashboard and your Search Console report agree
  • You are debugging poor Core Web Vitals and need to know which element or interaction is responsible: the attribution build gives you largestShiftTarget for CLS, interactionTarget plus the input delay, processing, and presentation split for INP, and the four LCP subparts
  • You own your analytics pipeline and want to send metrics to your own endpoint or into GA4 as events, instead of paying a real user monitoring vendor
  • You need correct handling of the awkward parts: back/forward cache restores, prerendered pages, pages that load in a background tab, and metrics that keep changing until the page is hidden
  • You are measuring a single page application and want Core Web Vitals attributed to soft navigations, which version 6 added for Chromium 151 and newer
Skip it if

Setup reality

npm install web-vitals gives you a dependency-free package with a proper exports map, ESM, UMD, and IIFE builds, per-metric subpath entries such as web-vitals/onCLS.js, and TypeScript types. The library uses the buffered flag on PerformanceObserver, so it deliberately does not need to load early; the README recommends deferring it behind your user-facing code. The first real decision is which build. The standard build is roughly 3 KB brotli compressed and gives you numbers; the attribution build is about 1.5 KB more and gives you the reason behind them, and you switch by changing the import specifier to web-vitals/attribution. The second is that callbacks are not guaranteed to fire once, or at all. CLS and INP report again every time the page is hidden, all metrics report again after a back/forward cache restore with a new id, and a page the user never interacts with produces no INP. Any code that assumes one callback per metric per page view will double count or under count. There is also an explicit warning against calling onCLS or onINP more than once per page load, because each call registers its own observer and listeners for the lifetime of the page, which is easy to trip in a component that remounts. Finally, on version 6 the soft navigation support changes when the first page's metrics finalise once reportSoftNavs is on, so turning it on is a data change, not just a feature flag.

Patterns

Start measuring the three Core Web Vitalsmeasure-core-web-vitals

import {onCLS, onINP, onLCP} from 'web-vitals';

onCLS(console.log);
onINP(console.log);
onLCP(console.log);

// each logs: {name, value, delta, rating, id, entries, navigationType}

Call each function exactly once per page load. Every call creates its own PerformanceObserver and page-lifetime listeners, and the README warns that repeated calls accumulate memory. In React, this belongs in a module-level side effect or an effect with an empty dependency array, not in a component body.

Beacon each metric to your own collectorsend-to-your-endpoint

import {onCLS, onINP, onLCP, onFCP, onTTFB} from 'web-vitals';

function send(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    id: metric.id,
    page: metric.navigationURL ?? location.pathname,
  });
  navigator.sendBeacon('/analytics', body);
}

[onCLS, onINP, onLCP, onFCP, onTTFB].forEach((fn) => fn(send));

sendBeacon is the right transport because it survives page unload, but it caps payload size and gives you no response. Use metric.navigationURL rather than location.href once soft navigations are on, since a metric can be reported after the URL has already changed.

Send one request instead of fivebatch-before-sending

const queue = new Set();
const add = (metric) => queue.add(metric);

[onCLS, onINP, onLCP].forEach((fn) => fn(add));

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden' && queue.size) {
    navigator.sendBeacon('/analytics', JSON.stringify([...queue]));
    queue.clear();
  }
});

visibilitychange to hidden is the last reliable hook; beforeunload and unload do not fire on mobile and break bfcache eligibility. Do not JSON.stringify the metric objects wholesale in production, because entries carries the raw PerformanceEntry objects and inflates the payload.

Deduplicate values that are reported more than oncehandle-repeat-reports

function send({name, id, delta, value}) {
  // Providers that overwrite: key on `id` and send `value`.
  // Providers that sum: send `delta` and sum by `id`.
  post({name, id, value, delta});
}

onCLS(send);  // fires again each time the page is hidden
onINP(send);  // and again after a bfcache restore, with a new id

A back/forward cache restore is treated as a new page visit and produces a fresh id with navigationType 'back-forward-cache'. Summing raw values instead of deltas per id is the most common way people end up with impossible CLS numbers.

Find out which interaction is slow and whyattribution-for-inp

import {onINP} from 'web-vitals/attribution';

onINP(({value, attribution}) => {
  console.log(value, {
    target: attribution.interactionTarget,   // CSS selector
    type: attribution.interactionType,       // 'pointer' | 'keyboard'
    inputDelay: attribution.inputDelay,
    processing: attribution.processingDuration,
    presentation: attribution.presentationDelay,
    longestScript: attribution.longestScript,
  });
});

The three durations sum to the INP value, which tells you where to look: input delay means the main thread was already busy, processing means your handler is slow, presentation delay means rendering is. longestScript comes from Long Animation Frames and is Chromium only.

Break LCP into its four subpartsattribution-for-lcp

import {onLCP} from 'web-vitals/attribution';

onLCP(({value, attribution}) => {
  console.log(attribution.target, attribution.url);
  console.log({
    ttfb: attribution.timeToFirstByte,
    loadDelay: attribution.resourceLoadDelay,
    loadDuration: attribution.resourceLoadDuration,
    renderDelay: attribution.elementRenderDelay,
  });
});

The four numbers add up to the LCP value. A large resourceLoadDelay usually means late discovery, so preload the hero image; a large elementRenderDelay usually means render-blocking work or a client-side render gate.

Name the element that shiftedattribution-for-cls

import {onCLS} from 'web-vitals/attribution';

onCLS(({value, attribution}) => {
  console.log(value, {
    target: attribution.largestShiftTarget,
    at: attribution.largestShiftTime,
    score: attribution.largestShiftValue,
    loadState: attribution.loadState,
  });
});

This reports the single largest shift, not every shift, which is deliberate: fixing the biggest one usually moves the metric most. Every field is optional and absent when CLS is 0, so use optional access.

Report your own element identifiers instead of CSS selectorscustom-selector

import {onLCP} from 'web-vitals/attribution';

onLCP(sendToAnalytics, {
  generateTarget: (el) => el?.dataset?.testid,  // fall back to default
});

Returning null or undefined falls back to the built-in selector generator. Worth doing when your class names are hashed by a CSS-in-JS build, because default selectors then change on every deploy and your grouping breaks.

Report into Google Analytics 4send-to-ga4

import {onCLS, onINP, onLCP} from 'web-vitals';

function toGA4({name, delta, value, id, rating}) {
  gtag('event', name, {
    value: delta,        // deltas so GA can sum them
    metric_id: id,       // needed to aggregate
    metric_value: value,
    metric_rating: rating,
  });
}

onCLS(toGA4);
onINP(toGA4);
onLCP(toGA4);

Send delta as the event value and keep the full value in a custom parameter, because GA4 sums event values. metric_id is what lets you reconstruct a single page view's total later in BigQuery.

Watch values update while you developdebug-locally

import {onCLS, onINP} from 'web-vitals';

onCLS(console.log, {reportAllChanges: true});
onINP(console.log, {reportAllChanges: true, durationThreshold: 16});

reportAllChanges fires when the metric changes, not on every input to it, so a layout shift that does not raise CLS still logs nothing. durationThreshold defaults to 40ms for INP; lowering it surfaces interactions you would otherwise never see. Neither belongs in production.

Measure metrics for SPA route changessoft-navigations

import {onCLS, onINP, onLCP} from 'web-vitals';

// hard navigations, as before
onCLS(reportHard);
onINP(reportHard);
onLCP(reportHard);

// soft navigations, Chromium 151+
onCLS(reportSoft, {reportSoftNavs: true});
onINP(reportSoft, {reportSoftNavs: true});
onLCP(reportSoft, {reportSoftNavs: true});

New in version 6, and it changes your existing data: with reportSoftNavs on, the initial page's metrics finalise at the first soft navigation instead of at page hide. Browsers without support ignore the flag, so your dataset splits by browser. Always read metric.navigationURL rather than the current URL.

Read the good and poor cutoffsuse-thresholds

import {CLSThresholds, INPThresholds, LCPThresholds} from 'web-vitals';

console.log(CLSThresholds); // [0.1, 0.25]
console.log(INPThresholds); // [200, 500]
console.log(LCPThresholds); // [2500, 4000]

Use these for axis lines and alert thresholds in your own charts. For classifying a single measurement, read metric.rating instead: the library already applied the same cutoffs and the README says not to recompute it by hand.

Alternatives

PackageRegistryPick it when
perfume.jsnpmYou want Core Web Vitals plus navigation timing, resource timing, and device context from one script.
@vercel/speed-insightsnpmYou are on Vercel and want a hosted dashboard rather than building the collection pipeline yourself.
@sentry/browsernpmYou already ship Sentry and want Web Vitals attached to sessions and errors with no extra collection code.
lighthousenpmYou need reproducible lab measurements in CI rather than field data from real users.