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-vitalsBasic 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 poorThis 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.