Skip to content

Real User Monitoring and Web Vitals

intermediate20 min read

Lab Data Lies (a Little)

You run Lighthouse on your MacBook Pro, connected to gigabit WiFi, and get a performance score of 98. Beautiful. Ship it.

Three weeks later, your Search Console shows poor Core Web Vitals for 30% of your users. What happened? Your MacBook is not your users. Your gigabit WiFi is not their 3G connection. Your empty browser with no extensions is not their browser with 40 tabs, an ad blocker, a password manager, and three accessibility extensions fighting over the DOM.

Lab data (Lighthouse, WebPageTest) tells you how your site performs under controlled conditions. Real User Monitoring (RUM) tells you how it performs for actual humans, on their actual devices, over their actual networks. Both are valuable, but only RUM tells you the truth about your users' experience.

Mental Model

Lab testing is like a car manufacturer testing MPG on a dynamometer in a climate-controlled lab. RUM is like checking your actual gas mileage after a month of driving — with traffic, hills, A/C blasting, kids in the back, and a trunk full of groceries. The lab number goes on the sticker. The real number goes in your budget. You need both, but you live with the real one.

The Core Web Vitals

Google defines three Core Web Vitals that measure the user experience:

MetricWhat It MeasuresGoodNeeds ImprovementPoor
LCP (Largest Contentful Paint)How fast the main content appearsUnder 2.5s2.5s - 4.0sOver 4.0s
INP (Interaction to Next Paint)How fast the page responds to user inputUnder 200ms200ms - 500msOver 500ms
CLS (Cumulative Layout Shift)How much the layout unexpectedly movesUnder 0.10.1 - 0.25Over 0.25

Plus two supplementary metrics:

  • FCP (First Contentful Paint) — When the first text or image appears. A proxy for "is the page loading?"
  • TTFB (Time to First Byte) — How long until the server sends the first byte. Measures server responsiveness.

Google uses these metrics (specifically the 75th percentile across all page loads) as a ranking signal. Poor Web Vitals can directly impact your search position.

The web-vitals Library

Google's web-vitals library is the standard way to measure these metrics in the browser. It is tiny (about 1.5KB gzipped), uses the same PerformanceObserver APIs that Chrome uses internally, and provides consistent, accurate measurements.

pnpm add web-vitals

Basic Usage

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

function sendToAnalytics(metric: { name: string; value: number; id: string }) {
  fetch('/api/vitals', {
    method: 'POST',
    body: JSON.stringify({
      name: metric.name,
      value: metric.value,
      id: metric.id,
      page: window.location.pathname,
      connection: navigator.connection?.effectiveType,
      deviceMemory: navigator.deviceMemory,
    }),
    keepalive: true,
  });
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

A few critical details:

  • keepalive: true — This is essential. Without it, the fetch request is cancelled when the user navigates away. CLS and INP are reported on page hide (when the user leaves), so without keepalive, you lose the data.
  • Each callback fires once per page load with the final value. LCP fires when the largest element finishes rendering. CLS fires with the cumulative score when the page is hidden. INP fires with the worst interaction latency.
  • The id field uniquely identifies each measurement instance, useful for deduplication if your analytics endpoint receives the same event twice.
Quiz
You set up web-vitals but notice you are only receiving LCP and FCP data — no INP, CLS, or TTFB. What is the most likely cause?

The Attribution Build: Debugging Poor Metrics

The standard web-vitals library tells you "your LCP is 3.2 seconds." The attribution build tells you why. It breaks down each metric into components you can act on.

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

onLCP((metric) => {
  const attribution = metric.attribution;
  console.log({
    element: attribution.element,
    url: attribution.url,
    timeToFirstByte: attribution.timeToFirstByte,
    resourceLoadDelay: attribution.resourceLoadDelay,
    resourceLoadDuration: attribution.resourceLoadDuration,
    elementRenderDelay: attribution.elementRenderDelay,
  });
});

For an LCP of 3200ms, the attribution might show:

{
  element: "img.hero-image",
  url: "/images/hero.webp",
  timeToFirstByte: 800,
  resourceLoadDelay: 400,
  resourceLoadDuration: 1200,
  elementRenderDelay: 800
}

Now you know: 800ms is TTFB (server problem), 400ms is the delay before the image even starts loading (missing preload hint), 1200ms is the image download (large file or slow CDN), and 800ms is render delay (render-blocking CSS or JavaScript). Each number points to a specific optimization.

INP Attribution with LoAF

INP attribution in the latest web-vitals versions uses the Long Animation Frames (LoAF) API to provide detailed breakdowns:

onINP((metric) => {
  const attribution = metric.attribution;
  console.log({
    interactionTarget: attribution.interactionTarget,
    interactionType: attribution.interactionType,
    inputDelay: attribution.inputDelay,
    processingDuration: attribution.processingDuration,
    presentationDelay: attribution.presentationDelay,
  });
});

For an INP of 350ms, you might see:

{
  interactionTarget: "button#search-submit",
  interactionType: "pointer",
  inputDelay: 180,
  processingDuration: 120,
  presentationDelay: 50
}

The 180ms input delay means the main thread was busy when the user clicked (probably running JavaScript from a previous task). The 120ms processing means the event handler took too long. The 50ms presentation delay is the time for the browser to paint the result. Each component has a different fix: input delay needs task splitting, processing time needs handler optimization, presentation delay needs simpler DOM updates.

CLS attribution: which elements shifted

CLS attribution identifies the specific elements that caused layout shifts and what triggered them. The attribution includes the largestShiftTarget (the DOM element that moved the most), the largestShiftTime (when it happened), and the largestShiftValue (how much it contributed to the total CLS). This is invaluable because CLS errors are notoriously hard to reproduce — they depend on font loading timing, image dimensions, and dynamic content injection, all of which vary per user. With attribution data, you know exactly which element shifted and when, even if you cannot reproduce it locally.

Sending Data to Analytics Backends

Custom Endpoint

The most flexible approach is your own API endpoint that stores vitals in a time-series database:

function sendToAnalytics(metric: {
  name: string;
  value: number;
  id: string;
  attribution?: Record<string, unknown>;
}) {
  const body = {
    name: metric.name,
    value: metric.value,
    id: metric.id,
    page: window.location.pathname,
    timestamp: Date.now(),
    connection: navigator.connection?.effectiveType ?? 'unknown',
    deviceMemory: navigator.deviceMemory ?? 0,
    userAgent: navigator.userAgent,
  };

  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/vitals', JSON.stringify(body));
  } else {
    fetch('/api/vitals', {
      method: 'POST',
      body: JSON.stringify(body),
      keepalive: true,
    });
  }
}

navigator.sendBeacon is preferred over fetch with keepalive because it is specifically designed for sending data during page unload. It is fire-and-forget — the browser guarantees delivery even after the page is gone.

Google Analytics 4

If you already use GA4, you can send Web Vitals as custom events:

function sendToGA(metric: { name: string; value: number; id: string }) {
  gtag('event', metric.name, {
    value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
    event_category: 'Web Vitals',
    event_label: metric.id,
    non_interaction: true,
  });
}

Note the CLS * 1000 — CLS is a unitless score (like 0.12), but GA4 rounds event values to integers. Multiplying by 1000 preserves precision (0.12 becomes 120).

Quiz
You are sending Web Vitals to your analytics backend. After analyzing a week of data, you notice LCP values are suspiciously low — most are under 500ms. Your Lighthouse lab test shows 1800ms. What is happening?

Percentile-Based Reporting

Averages lie. If 90% of your users have an LCP of 1.5s and 10% have an LCP of 8s, the average is 2.15s — which looks fine. But those 10% are having a terrible experience, and the average hides them completely.

This is why Core Web Vitals use the 75th percentile (p75). Google's threshold says: "At least 75% of page loads should have an LCP under 2.5s." This means the 75th percentile user — the user at the boundary between "most" and "some" — must have a good experience.

Why p75 and Not p50 or p95?

  • p50 (median) — Too lenient. Half your users could have a bad experience and you would still pass.
  • p75 — The sweet spot. Ensures the vast majority of users have a good experience while not being so strict that a few outliers on 2G connections fail the metric.
  • p95 — Too strict for most applications. Outliers (users on extremely slow connections, users with browser extensions injecting DOM elements) would dominate, and you would chase ghosts.

Segmenting Your Data

Aggregate p75 is useful but not actionable. Segment by dimensions that affect performance:

const segments = {
  page: window.location.pathname,
  connection: navigator.connection?.effectiveType,
  deviceMemory: navigator.deviceMemory,
  country: getUserCountry(),
  deviceType: getDeviceType(),
  isReturningVisitor: hasVisitedBefore(),
};

Your homepage might have great LCP (simple layout), but your course page might have terrible LCP (heavy MDX rendering). Your US users might be fine, but your India users on 3G are suffering. Without segmentation, these problems hide in the aggregate.

Key Rules
  1. 1Always report p75, not averages — Google uses p75 for ranking and it is the industry standard
  2. 2Segment by page, connection type, device type, and geography at minimum
  3. 3Separate new visitors from returning visitors — cached vs uncached performance is dramatically different
  4. 4Set up alerts on p75 regression, not absolute thresholds — a 20% increase from your baseline matters more than crossing an arbitrary line
Quiz
Your p75 LCP is 2.4s (good). Your p95 LCP is 6.2s (poor). Should you invest time fixing the p95?
What developers doWhat they should do
Forgetting keepalive: true on the analytics fetch
Without keepalive, the browser cancels the request when the user navigates away. You lose CLS and INP data — the metrics that are reported on page hide.
Always use sendBeacon or fetch with keepalive for Web Vitals reporting
Reporting averages instead of percentiles
Averages are dominated by outliers and hide the distribution. A p75 of 2.4s and a p75 of 2.4s can have completely different distributions — one healthy, one bimodal with a long tail.
Report p50, p75, and p95 for every metric
Not using the attribution build for debugging
The standard build tells you the metric value. The attribution build tells you why. Without it, you know LCP is slow but not whether the bottleneck is TTFB, resource loading, or render delay.
Use web-vitals/attribution in development and for a sample of production data
Measuring Web Vitals only in development or staging
Lab data cannot replicate the diversity of real user conditions. A metric that looks good on your MacBook might be terrible on a budget Android phone on a 3G connection.
Measure in production with real users on real devices

From Data to Action

Collecting Web Vitals is step one. The real value is turning that data into a feedback loop: measure → identify regression → diagnose with attribution → fix → verify the fix in production data.

The teams with the best Web Vitals are not the ones with the fanciest dashboards. They are the ones who check their p75 numbers weekly, investigate any regression immediately, and treat Web Vitals like they treat test coverage — a metric that only goes up, never down.