StackPractices
intermediate By Mathias Paulenko

Real User Monitoring (RUM) Setup with web-vitals

Monitor actual user experiences with Core Web Vitals, session replay, and performance analytics to identify real-world bottlenecks.

Overview

Real User Monitoring (RUM) captures performance data from actual browser sessions — not synthetic tests or server-side metrics. It reveals how Core Web Vitals, JavaScript errors, and API latencies vary across devices, networks, and geographies. Unlike lab tests that run in ideal conditions, RUM exposes the experience of users on 3G networks, low-end devices, and older browsers.

This recipe wires up a minimal RUM pipeline: collect vitals with Google’s web-vitals library, ship them with sendBeacon, and add session replay on top. That covers the same ground commercial tools charge for, and it takes an afternoon.

When to Use

Use this resource when:

  • Lab-based Lighthouse scores don’t match real-world performance complaints
  • You need to correlate business metrics (conversion, bounce rate) with page speed
  • You’re debugging performance issues that only affect specific browsers or regions
  • You want to prioritize optimization work based on actual user impact, not assumptions

Solution

Collect Core Web Vitals (web-vitals v4)

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

function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
    rating: metric.rating, // 'good', 'needs-improvement', 'poor'
    delta: metric.delta,
    navigationType: metric.navigationType,
    page: window.location.pathname,
  });

  // sendBeacon survives page unload — a fetch might get cancelled
  const queued = navigator.sendBeacon?.('/analytics/vitals', body);
  if (!queued) {
    fetch('/analytics/vitals', { body, method: 'POST', keepalive: true });
  }
}

onCLS(sendToAnalytics);
onINP(sendToAnalytics);   // INP replaced FID in web-vitals v4
onFCP(sendToAnalytics);
onLCP(sendToAnalytics);
onTTFB(sendToAnalytics);

Two details worth knowing: web-vitals v4 reports INP instead of FID (FID was retired from Core Web Vitals in March 2024), and sendBeacon is what keeps events alive when the user closes the tab mid-measurement.

Session replay on top (Sentry)

import * as Sentry from '@sentry/browser';

Sentry.init({
  dsn: 'https://abc@sentry.io/1',
  integrations: [
    Sentry.replayIntegration({
      maskAllText: true,   // mask sensitive text in replays
      blockAllMedia: true, // don't capture images or video
    }),
  ],
  tracesSampleRate: 0.1,          // 10% of transactions
  replaysSessionSampleRate: 0.01, // 1% of normal sessions
  replaysOnErrorSampleRate: 1.0,  // 100% of sessions with an error
});

The standalone @sentry/replay package is deprecated — since SDK v7 replay ships inside @sentry/browser as replayIntegration().

Raw PerformanceObserver for custom metrics

When you need something the library doesn’t report — slow resources, long tasks, route timings — go to PerformanceObserver directly:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'resource' && entry.duration > 1000) {
      sendToAnalytics({
        name: 'slow-resource',
        value: entry.duration,
        id: entry.name,
        rating: 'poor',
      });
    }
  }
});

observer.observe({ entryTypes: ['resource', 'longtask'] });

Reuse your sendToAnalytics instead of console.log — a metric that stays on the user’s console never made it to your dashboard.

Explanation

RUM vs. synthetic monitoring

AspectRUMSynthetic
Data sourceReal usersScheduled bots
NetworkVariable (3G to 5G)Controlled (fast)
Device diversityFull rangeUsually desktop
GeographyActual user locationsData center
Use caseUnderstand realityBaseline regression checks

You need both, for different reasons: synthetic catches “did this deploy break performance?”, RUM answers “are real users actually suffering?”.

RUM data only becomes useful once you segment it. A global P75 of 3s can mean everything is slightly slow, or that mobile users on 3G are at 8s while desktop sits at 1.2s — same number, completely different problem. Tag every event with device class, effective connection type, and geography from day one; retrofitting dimensions onto already-collected data is impossible. Three or four dimensions are enough — a dozen tags just multiplies your cardinality.

The data flows like this:

flowchart diagram: Browser session

The metrics that matter

  • LCP (Largest Contentful Paint): when the biggest visible element renders — hero image, main heading. Good ≤ 2.5s.
  • INP (Interaction to Next Paint): latency from click or keypress to the next visual update. Replaced FID; good ≤ 200ms.
  • CLS (Cumulative Layout Shift): how much the layout jumps around — ads, images, and fonts are the usual suspects. Good ≤ 0.1.
  • TTFB (Time to First Byte): server response time — hosting plus backend. See server-side rendering if yours is slow.
  • FCP (First Contentful Paint): first text or image on screen. Not a Core Web Vital anymore, but still a useful early signal.

Single-page apps need extra work

web-vitals measures the initial page load. In a React/Vue SPA, every client-side route change is invisible to it — there’s no new navigation. Track route transitions yourself: time from the router’s navigate call to the first paint of the new route (a requestAnimationFrame after the route component mounts gets you close enough), and report it as a custom metric. Otherwise your “fast” dashboard is only measuring the landing page.

Route naming matters as much as the measurement itself. Report the route pattern (/products/:id), not the raw URL (/products/4821) — otherwise every item page becomes a separate series and your percentiles fragment into noise. Batch these custom metrics together with the vitals payload rather than firing a beacon per navigation; a user who clicks through ten routes shouldn’t cost you ten requests.

Variants

ToolTypeNotable Capabilities
Google CrUXFree, Chrome-onlyLargest real-world dataset; powers PageSpeed field data
web-vitals.jsOpen sourceGoogle’s reference implementation — what this recipe uses
SentryOpen source / SaaSErrors + performance + replay in one SDK
Datadog RUMCommercialCorrelation with APM; built-in session replay
New Relic BrowserCommercialSession traces; JS error tracking
SpeedCurveCommercialCompetitive benchmarking; filmstrip views

Roll your own when you want full control of the data and can maintain an ingest endpoint. Go commercial when you’d rather pay than operate a metrics pipeline — the pricing usually becomes reasonable next to the engineering time.

What Works

A few habits that make RUM data actionable instead of decorative:

  • Sample intelligently: 100% sampling overwhelms your backend and your bill; 5–10% is usually plenty.
  • Capture context: device type, connection speed, and country explain most of the variance — attach them to every event.
  • Alert on percentiles, not averages: P95 is what frustrated users experience; a mean of 2s can hide 20% of users at 8s.
  • Correlate with business metrics: plot conversion rate against LCP to justify optimization budgets. See metrics collection.
  • Respect privacy: mask PII in session replay, anonymize IPs, and honor consent for telemetry under GDPR/CCPA.

Common Mistakes

  1. Only monitoring the homepage — product pages and checkout usually perform worse, and they’re where the money is.
  2. Ignoring SPA navigations — single-page apps need custom measurement for route changes, or most of the session goes unmeasured.
  3. No correlation with errors — a slow page that also throws JS errors needs different prioritization. See error handling.
  4. Alerting on averages — a mean LCP of 2s hides the fifth of your users staring at 8s+ loads.
  5. Collecting data nobody acts on — RUM without scheduled optimization sprints is expensive logging.

Further Reading

Frequently Asked Questions

Does RUM slow down my site?

Negligibly. The web-vitals library is about 1KB, and beacons go out after the page is interactive. Session replay is heavier — that's why you sample it at 1% instead of 100%.

Should I use RUM or synthetic monitoring?

Both. Synthetic catches regressions on deploy; RUM tells you what actual users experience on their devices and networks. One without the other leaves a blind spot.

How do I handle ad blockers?

Serve the RUM script and the ingest endpoint from your own domain (first-party). Ad blockers target known analytics domains, so rum.yourdomain.com usually survives — expect a few percent of sessions to slip through anyway.

How do I choose a RUM tool?

By what you already run and what you need to answer. Datadog RUM if you want full-stack correlation; Sentry if errors are your main concern; CrUX if you only need Core Web Vitals benchmarks; roll your own with web-vitals if you want the raw data. Whatever you pick, verify in staging that the numbers match what the lab tools report.

What sampling rate should I start with?

5–10% of sessions for vitals, 1% for replay, 100% for error sessions. Push the vital rate up for low-traffic sites (you need the volume for meaningful percentiles) and down once you see the bill.