RenderloriaRender · Measure · Refine Request a rendering review

Guide

Building a real-user monitoring pipeline with web-vitals

You do not need a large product to start measuring real users. A small script, an endpoint and a table can give you Core Web Vitals by route and device, with enough attribution to find causes. This guide walks through each part and the privacy choices along the way.

Why collect your own field data

The Chrome UX Report (CrUX) offers free field data for many origins and popular pages, measured over a rolling 28-day window and published at the 75th percentile (source 1). It is the dataset behind the field section of PageSpeed Insights and Search Console's Core Web Vitals report, and it is the right first look.

It has limits for engineering work. It covers Chrome users only, smaller pages may have too little traffic to appear, it reports values without saying which element or script caused them, and changes take weeks to show. Your own collection answers the questions CrUX cannot: which route, which device class, which interaction, which script.

Part 1: the collector

Google's open-source web-vitals library measures the Core Web Vitals the same way Chrome does, and its attribution build adds diagnostic fields (source 2). Install it from npm and import from the attribution entry point:

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

const queue = [];
const context = {
  route: document.documentElement.dataset.route || 'unknown', // set this to your route template
  build: window.APP_BUILD || 'dev',
  deviceMemory: navigator.deviceMemory || null,
  effectiveType: navigator.connection ? navigator.connection.effectiveType : null
};

function record(metric) {
  queue.push({
    name: metric.name,
    value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
    rating: metric.rating,
    navigationType: metric.navigationType,
    detail: summarize(metric)
  });
}

onLCP(record);
onINP(record);
onCLS(record);

deviceMemory and connection are available in Chromium-based browsers only; the code stores null elsewhere. CLS is multiplied by 1,000 here so every value can be stored as an integer; remember to divide when reading.

Keep attribution small and safe

The attribution object is rich, but you do not need all of it. A summarize function picks a few fields per metric:

function trim(selector) {
  return selector ? String(selector).replace(/[#\[].*$/, '').slice(0, 80) : null;
}

function summarize({ name, attribution: a }) {
  if (name === 'LCP') {
    return { target: trim(a.target), ttfb: Math.round(a.timeToFirstByte), loadDelay: Math.round(a.resourceLoadDelay),
             loadDuration: Math.round(a.resourceLoadDuration), renderDelay: Math.round(a.elementRenderDelay) };
  }
  if (name === 'INP') {
    const scripts = (a.longAnimationFrameEntries || []).flatMap((f) => [...f.scripts])
      .sort((x, y) => y.duration - x.duration).slice(0, 2)
      .map((s) => ({ invoker: s.invoker, file: (s.sourceURL || '').split('?')[0], ms: Math.round(s.duration) }));
    return { target: trim(a.interactionTarget), type: a.interactionType, inputDelay: Math.round(a.inputDelay),
             processing: Math.round(a.processingDuration), presentation: Math.round(a.presentationDelay), scripts };
  }
  return { target: trim(a.largestShiftTarget) };
}

The LCP fields follow Google's four-way split of that metric: server response, the wait before the image or text resource is requested, its download, and the delay before it paints (source 3). The INP fields carry the three phases and the top scripts from overlapping Long Animation Frames (source 2). The trim function cuts selectors at the first ID or attribute selector and strips query strings from script URLs, so user-generated text and identifiers are less likely to leave the page.

Part 2: sending the data

Metrics such as INP and CLS keep changing until the page is hidden, so the library reports them when the page's visibility changes (source 2). Send the queue at that moment, using navigator.sendBeacon so the request survives the page closing:

addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden' || !queue.length) return;
  const body = JSON.stringify({ ...context, metrics: queue.splice(0) });
  navigator.sendBeacon('/rum', new Blob([body], { type: 'application/json' }));
});

Register this listener after the web-vitals callbacks so the final values are queued before the send runs. If you use a strict Content Security Policy, the endpoint must be allowed by connect-src.

Sampling

You rarely need every page view. Decide once per page load whether to collect, for example 10 percent of views on a busy app, and keep the decision for the whole visit so metrics from one page view stay together. Increase the rate on low-traffic routes you care about, or temporarily after a release.

Part 3: storage

A single table is enough to start. One row per metric per page view:

A minimal schema for field metrics
ColumnExampleNotes
received_at2026-11-04 14:05 UTCSet by the server
route/products/[slug]A template, never the full URL
build2026.11.03-2Lets you compare releases
metricINPLCP, INP or CLS
value184Milliseconds, or CLS times 1,000
ratinggoodFrom the library
device_memory4Null outside Chromium
detailJSONThe summarized attribution

Do not store IP addresses or user identifiers in this table. If your endpoint's web server logs IP addresses by default, set a short retention for those logs.

Part 4: reading the numbers

Core Web Vitals are judged at the 75th percentile of page loads, split by mobile and desktop (source 4). Report the same way, by route:

SELECT route,
       metric,
       COUNT(*) AS samples,
       PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) AS p75
FROM vitals
WHERE received_at > NOW() - INTERVAL '28 days'
GROUP BY route, metric
HAVING COUNT(*) >= 200
ORDER BY metric, p75 DESC;

The syntax shown is PostgreSQL; other databases have equivalent percentile functions. The minimum sample count avoids drawing conclusions from a handful of visits; choose a threshold that suits your traffic. Then drill into the detail column for the worst routes: group INP rows by target and look at the dominant phase and the scripts, as described in our INP debugging guide.

Checking that the data is trustworthy

Before acting on field data, check it. Compare your own 75th percentile values for a busy route with the Chrome UX Report for the same page; they will not match exactly, because the populations differ, but a large gap suggests a collection problem. Check sample counts per route and device class. Look for metrics that never arrive from some browsers, which is expected for INP attribution outside Chromium but not for LCP or CLS in browsers that support them. And check that the build field changes on release, so before-and-after comparisons group visits correctly.

Privacy and Canadian law

Performance data can still be personal information. In Canada, PIPEDA generally governs personal information that businesses handle in their commercial activities, and its fair information principles include limiting collection to what is needed and being open about practices (source 5). In Quebec, section 8.1 of the private-sector act, added by Law 25, requires businesses using technology that can identify, locate or profile a person to inform them and explain how to activate those functions; commentators such as McCarthy Tétrault read the wording as implying those functions should be off by default (source 6).

A collector designed like the one above, with no identifiers, trimmed selectors, route templates instead of URLs and no stored IP addresses, aims to collect aggregate technical measurements rather than information about individuals. Whether that holds for your setup, and what your privacy notice should say, is a question for your legal advisers. This guide is not legal advice.

Practical steps that help in any case:

  • List the fields you collect in your privacy notice.
  • Never send full URLs, query strings, form values or free text.
  • Keep raw rows only as long as you need them, for example 90 days, and keep aggregates longer.
  • Restrict access to the raw table.

Where to go next

Once data is flowing, set a regression alert on p75 per route after each release, and add a dashboard of INP phases for your top interactions. If you prefer a commercial monitoring product, the same thinking applies when you configure it: collect attribution, avoid personal data, and read at the 75th percentile.

Sources

  1. How to use the CrUX History API, Chrome for Developers Primary source (Google)
  2. Measuring Interaction to Next Paint (INP) codelab, web.dev Primary source (Google)
  3. Optimize Largest Contentful Paint, web.dev Primary source (Google)
  4. Interaction to Next Paint (INP), web.dev Primary source (Google)
  5. PIPEDA fair information principles, Office of the Privacy Commissioner of Canada Primary source (regulator)
  6. Québec's Law 25 and cookies: not so cookie cutter, McCarthy Tétrault Secondary source (law firm commentary)

Ready when your team is

Plan your rendering review with us

Tell us about your app, its framework and where it feels slow. We reply with questions and a fixed written quote.

Start a conversation
  • Fixed written quote
  • Written findings
  • No obligation