Real User Monitoring Implementation: Capturing Web Vitals from Actual Users

Real User Monitoring Implementation: Capturing Web Vitals from Actual Users

Synthetic monitoring (Lighthouse, Playwright) tells you how your app performs in a controlled environment. Real User Monitoring (RUM) tells you how it actually performs for your users—on their devices, their networks, their browsers, with their extensions installed and their CPU under load from 40 other tabs.

These are different numbers. Often very different. This guide covers implementing RUM from scratch, without relying on expensive third-party tools.

Why Synthetic and RUM Numbers Diverge

Your Lighthouse CI passes: LCP 1.8s, CLS 0.04. Your RUM data shows median LCP 3.2s, 75th percentile CLS 0.18.

This is normal. The gap comes from:

  • Device diversity: Your CI runs on a modern server CPU. Your users have 4-year-old mid-range phones.
  • Network conditions: CI simulates 4G. Rural users are on 3G; corporate users are behind proxies.
  • Third-party scripts: Ad networks, analytics, chat widgets—they load asynchronously but still block LCP.
  • Cache state: CI always starts cold. Returning users have warm caches.
  • Extensions: Chrome extensions can inject scripts that shift CLS or block rendering.
  • Geographic distribution: CDN latency to users in Southeast Asia is different than to users in New York.

Neither number is "more correct"—they measure different things. You need both.

Implementing RUM with web-vitals.js

Google's web-vitals library is the reference implementation for Core Web Vitals measurement in the browser. It handles all the edge cases: LCP buffering, CLS windowing, INP observation, page visibility events that finalize metrics.

npm install web-vitals

Basic setup:

// src/monitoring/web-vitals.js
import { onLCP, onCLS, onINP, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,  // 'good', 'needs-improvement', 'poor'
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
    // Add your own dimensions
    url: window.location.href,
    userAgent: navigator.userAgent,
    connection: navigator.connection?.effectiveType,
    timestamp: Date.now(),
  });
  
  // Use sendBeacon for reliability (fires even on page unload)
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/metrics', body);
  } else {
    // Fallback for older browsers
    fetch('/api/metrics', { body, method: 'POST', keepalive: true });
  }
}

// Register all vital observers
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

Call this early in your application bootstrap—before the app renders, if possible:

// src/index.js
import './monitoring/web-vitals';  // First import

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));

Building the Metrics Collection Endpoint

// api/metrics.js (Node.js / Express)
const express = require('express');
const router = express.Router();

// In-memory buffer — flush to your actual storage
const metricsBuffer = [];
const FLUSH_INTERVAL = 10000;  // 10 seconds
const BATCH_SIZE = 100;

router.post('/api/metrics', express.json(), (req, res) => {
  const metric = req.body;
  
  // Validate
  if (!metric.name || !['LCP', 'CLS', 'INP', 'FCP', 'TTFB'].includes(metric.name)) {
    return res.status(400).json({ error: 'Invalid metric name' });
  }
  
  if (typeof metric.value !== 'number' || metric.value < 0) {
    return res.status(400).json({ error: 'Invalid metric value' });
  }
  
  // Sanitize user-agent (don't store full UA strings)
  const browser = parseBrowser(metric.userAgent);
  
  metricsBuffer.push({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    url: new URL(metric.url).pathname,  // Store path, not full URL
    connection: metric.connection,
    browser: browser,
    timestamp: new Date().toISOString(),
  });
  
  res.status(200).json({ ok: true });
  
  // Flush if buffer is large
  if (metricsBuffer.length >= BATCH_SIZE) {
    flushMetrics();
  }
});

function parseBrowser(ua) {
  if (/Chrome\//.test(ua)) return 'chrome';
  if (/Firefox\//.test(ua)) return 'firefox';
  if (/Safari\//.test(ua) && !/Chrome/.test(ua)) return 'safari';
  if (/Edg\//.test(ua)) return 'edge';
  return 'other';
}

async function flushMetrics() {
  if (!metricsBuffer.length) return;
  
  const batch = metricsBuffer.splice(0, BATCH_SIZE);
  
  // Write to your storage — PostgreSQL, ClickHouse, BigQuery, etc.
  await db.insertMetrics(batch);
}

setInterval(flushMetrics, FLUSH_INTERVAL);

Storage Schema

For RUM data, you need fast aggregation queries (percentiles, group by URL/browser/date). ClickHouse or TimescaleDB outperform plain PostgreSQL for this, but PostgreSQL works fine at reasonable scale.

CREATE TABLE web_vitals (
  id UUID DEFAULT gen_random_uuid(),
  name VARCHAR(10) NOT NULL,          -- LCP, CLS, INP, etc.
  value FLOAT NOT NULL,
  rating VARCHAR(20),                  -- good, needs-improvement, poor
  url VARCHAR(500),
  browser VARCHAR(50),
  connection VARCHAR(20),              -- 4g, 3g, etc.
  timestamp TIMESTAMPTZ NOT NULL,
  
  -- Partitioned by day for query performance
  created_date DATE GENERATED ALWAYS AS (timestamp::date) STORED
) PARTITION BY RANGE (created_date);

-- Create partitions for the next 30 days
-- In production, automate this

CREATE INDEX idx_web_vitals_name_timestamp ON web_vitals (name, timestamp);
CREATE INDEX idx_web_vitals_url ON web_vitals (url, timestamp);

Percentile Analysis

Never report average Web Vitals. Use percentiles—specifically p75, which matches how Google measures Core Web Vitals in Search Console.

-- P75 LCP by URL for the last 7 days
SELECT 
  url,
  COUNT(*) as sample_count,
  PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY value) as p50_ms,
  PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75_ms,
  PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY value) as p95_ms,
  ROUND(100.0 * SUM(CASE WHEN rating = 'good' THEN 1 ELSE 0 END) / COUNT(*), 1) as pct_good
FROM web_vitals
WHERE 
  name = 'LCP'
  AND timestamp > NOW() - INTERVAL '7 days'
GROUP BY url
HAVING COUNT(*) > 100  -- Minimum sample size
ORDER BY p75_ms DESC;
-- CLS distribution by browser
SELECT 
  browser,
  COUNT(*) as sessions,
  PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75_cls,
  ROUND(100.0 * SUM(CASE WHEN rating = 'poor' THEN 1 ELSE 0 END) / COUNT(*), 1) as pct_poor
FROM web_vitals
WHERE 
  name = 'CLS'
  AND timestamp > NOW() - INTERVAL '30 days'
GROUP BY browser
ORDER BY p75_cls DESC;

Segmentation: Finding Who Has a Bad Experience

The median often looks fine. Your problems are hiding in segments:

-- Performance by connection type
SELECT 
  connection,
  PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75_lcp,
  COUNT(*) as sessions
FROM web_vitals
WHERE name = 'LCP' AND timestamp > NOW() - INTERVAL '7 days'
GROUP BY connection
ORDER BY p75_lcp;

-- Results might look like:
-- 4g:     p75=1.8s (10,000 sessions) — fine
-- 3g:     p75=4.2s (2,000 sessions) — poor
-- slow-2g: p75=9.8s (500 sessions) — very poor

This tells you that 2,500 sessions per week have a poor experience, and it's network-related. That's actionable.

-- Performance trend over time (daily)
SELECT 
  DATE_TRUNC('day', timestamp) as day,
  PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75_lcp,
  COUNT(*) as sessions
FROM web_vitals
WHERE 
  name = 'LCP'
  AND url = '/'
  AND timestamp > NOW() - INTERVAL '30 days'
GROUP BY day
ORDER BY day;

Trending helps you detect regressions: did the LCP increase after last Thursday's deploy?

Setting Up Alerting

A dashboard nobody watches is theater. Alerting on RUM regressions closes the loop:

// scripts/check-vitals-regression.js
// Run this on a cron or after deployments

const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function checkVitalsRegression() {
  // Compare last 2 hours against previous 24h average
  const result = await pool.query(`
    WITH recent AS (
      SELECT 
        name,
        PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75
      FROM web_vitals
      WHERE 
        timestamp > NOW() - INTERVAL '2 hours'
        AND url = '/'
      GROUP BY name
    ),
    baseline AS (
      SELECT 
        name,
        PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75
      FROM web_vitals
      WHERE 
        timestamp BETWEEN NOW() - INTERVAL '26 hours' AND NOW() - INTERVAL '2 hours'
        AND url = '/'
      GROUP BY name
    )
    SELECT 
      r.name,
      r.p75 as recent_p75,
      b.p75 as baseline_p75,
      ROUND(100.0 * (r.p75 - b.p75) / b.p75, 1) as pct_change
    FROM recent r
    JOIN baseline b ON r.name = b.name
    WHERE r.p75 > b.p75 * 1.2  -- 20% regression threshold
  `);
  
  if (result.rows.length > 0) {
    const message = result.rows.map(r => 
      `${r.name}: ${r.recent_p75}ms vs ${r.baseline_p75}ms baseline (+${r.pct_change}%)`
    ).join('\n');
    
    await sendSlackAlert(`⚠️ Web Vitals regression detected:\n${message}`);
  }
}

Connecting RUM to Deploy Events

The most useful question in performance monitoring: "Did this deploy make things worse?"

// Record deploy events in the same database
await db.query(`
  INSERT INTO deploy_events (version, deployed_at, git_sha)
  VALUES ($1, $2, $3)
`, [process.env.APP_VERSION, new Date(), process.env.GIT_SHA]);

Then overlay deploy events in your dashboards:

-- LCP over time with deploy markers
SELECT 
  DATE_TRUNC('hour', w.timestamp) as hour,
  PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY w.value) as p75_lcp,
  MAX(d.version) as deploy_version  -- Shows deploys that landed in this hour
FROM web_vitals w
LEFT JOIN deploy_events d 
  ON d.deployed_at BETWEEN 
    DATE_TRUNC('hour', w.timestamp) 
    AND DATE_TRUNC('hour', w.timestamp) + INTERVAL '1 hour'
WHERE 
  w.name = 'LCP'
  AND w.timestamp > NOW() - INTERVAL '7 days'
GROUP BY hour
ORDER BY hour;

Sending RUM Data to Google Analytics

If you already use GA4, you can forward web vitals data there and use it in your existing dashboards:

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

function sendToGA4(metric) {
  // gtag must be loaded first
  if (typeof gtag === 'undefined') return;
  
  gtag('event', metric.name, {
    value: Math.round(
      metric.name === 'CLS' ? metric.value * 1000 : metric.value
    ),
    metric_id: metric.id,
    metric_value: metric.value,
    metric_delta: metric.delta,
    metric_rating: metric.rating,
  });
}

onLCP(sendToGA4);
onCLS(sendToGA4);
onINP(sendToGA4);

GA4 then shows Core Web Vitals in the Core Web Vitals report (under Engagement), with the same percentile analysis and field data that feeds into Search Console.

Sample Size and Statistical Confidence

RUM data is only reliable with sufficient sample sizes. Rules of thumb:

  • Minimum for trending: 1,000 sessions/day per URL
  • Minimum for segmentation: 200+ sessions per segment
  • Minimum for A/B comparison: 500+ sessions per variant

Below these thresholds, the numbers are noise. A single user with a slow 3G connection can shift your median LCP by 500ms if you only have 50 daily sessions.

For low-traffic pages, aggregate by week instead of day. For very low-traffic pages, don't use RUM for performance decisions—use Lighthouse, which gives you deterministic results regardless of traffic.

RUM vs Synthetic: When Each Is Right

Question Use
"Will this deploy regress performance?" Synthetic (Playwright, Lighthouse CI)
"How is our homepage performing for users in Brazil?" RUM
"Did last week's deploy make CLS worse?" RUM (if traffic is sufficient)
"Does the new image lazy-loading break LCP?" Synthetic (A/B in Playwright)
"What % of users experience poor INP?" RUM
"Is our new checkout flow under our 2s budget?" Synthetic

The pattern: synthetic for prevention (catch regressions before deploy), RUM for observation (understand what's actually happening in production).


RUM implementation is a one-time investment that pays off continuously. Once the web-vitals.js snippet is in your codebase and metrics are flowing to your database, you have field data that tells you what your actual users experience—which is ultimately the only number that matters.

Pair it with HelpMeTest's continuous monitoring to get alerting on top: when your RUM p75 LCP crosses a threshold, or when a deploy triggers a sudden spike in poor-experience sessions, you want to know within minutes, not days.

Read more

Start now free