Skip to main content

Real-User Monitoring Setup: web-vitals & Custom Events

Real-User Monitoring (RUM) captures performance metrics from real users visiting your React app in production. Unlike lab tests (Lighthouse), RUM shows you actual performance under real-world conditions—slow networks, older devices, geographic regions, specific user flows. Setting up RUM involves three steps: instrument the web-vitals library, add custom business metrics, and send everything to your analytics backend for storage and alerting.

Installing and Configuring web-vitals

The web-vitals library is maintained by Google and requires one line to install:

npm install web-vitals

Create a monitoring module in your app:

// src/monitoring/rum.js
import {
getLCP,
getINP,
getCLS,
getFCP,
getLLCP,
} from 'web-vitals';

// Configuration
const RUM_ENDPOINT = '/api/rum'; // Your backend endpoint
const SESSION_ID = generateSessionId();

function generateSessionId() {
// Generate a unique session ID for grouping related metrics
const now = Date.now();
const rand = Math.random().toString(36).substring(2, 9);
return `${now}-${rand}`;
}

// Helper to send metrics to backend
async function sendMetric(metric) {
const payload = {
// Web Vital
metric: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,

// Context
sessionId: SESSION_ID,
url: window.location.href,
pathname: window.location.pathname,
timestamp: new Date().toISOString(),

// Device/Network
userAgent: navigator.userAgent,
effectiveType: navigator.connection?.effectiveType || 'unknown',
deviceMemory: navigator.deviceMemory || null,

// Browser/Page
doctype: document.doctype?.name || null,
docSize: document.body?.innerHTML.length || null,
};

try {
await fetch(RUM_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true, // Ensure request completes if user leaves
});
} catch (err) {
console.error('RUM send failed:', err);
// Optionally queue for retry, but don't block app
}
}

// Register all Core Web Vitals
export function initializeRUM() {
// Largest Contentful Paint (load speed)
getLCP(metric => {
sendMetric(metric);
});

// Interaction to Next Paint (responsiveness)
getINP(metric => {
sendMetric(metric);
});

// Cumulative Layout Shift (visual stability)
getCLS(metric => {
sendMetric(metric);
});

// First Contentful Paint (bonus metrics for context)
getFCP(metric => {
sendMetric(metric);
});

// Largest Contentful Paint (when available in newer Chrome)
if (getLLCP) {
getLLCP(metric => {
sendMetric(metric);
});
}

console.log('RUM initialized, sessionId:', SESSION_ID);
}

Call this in your app's entry point:

// src/main.jsx (or src/index.js)
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { initializeRUM } from './monitoring/rum';

// Initialize RUM before rendering
initializeRUM();

ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

Custom Business Metrics

Web Vitals measure technical performance, but your app needs business metrics too: form submission time, page load to first content interaction, cart add-to-checkout flow duration, etc.

// src/monitoring/custom-metrics.js

const SESSION_ID = window.__SESSION_ID__; // From rum.js
const RUM_ENDPOINT = '/api/rum';

export class CustomMetrics {
static async recordEvent(eventName, data = {}) {
const payload = {
type: 'custom-event',
event: eventName,
sessionId: SESSION_ID,
url: window.location.href,
timestamp: new Date().toISOString(),
...data, // Custom fields (user ID, page context, etc.)
};

try {
await fetch(RUM_ENDPOINT, {
method: 'POST',
body: JSON.stringify(payload),
keepalive: true,
});
} catch (err) {
console.error(`Failed to record event ${eventName}:`, err);
}
}

// Page-level metrics
static recordPageView(pageType) {
this.recordEvent('page-view', {
pageType, // 'product', 'checkout', 'home', etc.
});
}

// User interaction metrics
static recordInteraction(action, duration) {
this.recordEvent('interaction', {
action, // 'form-submit', 'search', 'filter', etc.
duration, // milliseconds
});
}

// Business conversion metrics
static recordConversion(conversionType, value) {
this.recordEvent('conversion', {
type: conversionType, // 'cart-add', 'purchase', 'signup', etc.
value, // monetary value for purchases
});
}

// Error tracking
static recordError(message, stack, context) {
this.recordEvent('error', {
message,
stack,
context, // React component, API call, etc.
});
}
}

Use custom metrics in your React components:

// src/pages/Checkout.jsx
import { useState } from 'react';
import { CustomMetrics } from '../monitoring/custom-metrics';

export default function Checkout({ cart }) {
const [loading, setLoading] = useState(false);

const handleSubmit = async (e) => {
e.preventDefault();

const startTime = performance.now();
setLoading(true);

try {
const response = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ cart }),
});

if (!response.ok) throw new Error('Checkout failed');

const data = await response.json();

// Record successful conversion
const duration = performance.now() - startTime;
CustomMetrics.recordConversion('purchase', cart.total);
CustomMetrics.recordInteraction('checkout-submit', duration);

// Redirect or show success
window.location.href = '/order-confirmation';
} catch (err) {
CustomMetrics.recordError(
err.message,
err.stack,
'Checkout.handleSubmit'
);
console.error(err);
} finally {
setLoading(false);
}
};

return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<button type="submit" disabled={loading}>
{loading ? 'Processing...' : 'Place Order'}
</button>
</form>
);
}

Backend: Storing and Querying RUM Data

You need a backend to receive and store RUM data. Here is a minimal example using Node.js/Express and a database:

// server/api/rum.js (Express.js)
import express from 'express';
import { db } from './db';

const router = express.Router();

router.post('/rum', async (req, res) => {
const {
metric,
value,
rating,
sessionId,
url,
pathname,
timestamp,
effectiveType,
deviceMemory,
type,
event,
duration,
context,
} = req.body;

try {
// Store in database
await db.query(`
INSERT INTO rum_events (
session_id, metric, value, rating, url, pathname,
timestamp, effective_type, device_memory, type, event,
duration, context
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
sessionId, metric, value, rating, url, pathname,
timestamp, effectiveType, deviceMemory, type, event,
duration, context,
]);

res.status(201).json({ ok: true });
} catch (err) {
console.error('RUM storage error:', err);
res.status(500).json({ error: err.message });
}
});

export default router;

Database schema:

CREATE TABLE rum_events (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(50),
metric VARCHAR(50), -- 'LCP', 'INP', 'CLS', 'custom-event'
value FLOAT, -- milliseconds or unitless
rating VARCHAR(20), -- 'good', 'needs-improvement', 'poor'
url TEXT,
pathname VARCHAR(255),
timestamp DATETIME,
effective_type VARCHAR(10), -- '4g', '3g', '2g'
device_memory INT, -- MB
type VARCHAR(50), -- 'custom-event', 'web-vital'
event VARCHAR(50), -- event name for custom events
duration FLOAT, -- milliseconds for custom events
context TEXT, -- JSON string for custom fields
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX(session_id),
INDEX(pathname),
INDEX(timestamp)
);

Analyzing RUM Data with SQL Queries

-- Core Web Vitals percentiles by page
SELECT
pathname,
metric,
COUNT(*) as samples,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY value) as p50,
AVG(CASE WHEN rating = 'good' THEN 1 ELSE 0 END) as pct_good
FROM rum_events
WHERE metric IN ('LCP', 'INP', 'CLS')
AND timestamp > DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY pathname, metric
ORDER BY pathname, metric;

-- INP by device type
SELECT
CASE
WHEN device_memory > 4 THEN 'desktop'
WHEN device_memory <= 4 THEN 'mobile'
ELSE 'unknown'
END as device,
metric,
COUNT(*) as samples,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75
FROM rum_events
WHERE metric = 'INP'
AND timestamp > DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY device, metric;

-- Conversion rate by network type
SELECT
effective_type,
COUNT(CASE WHEN event = 'conversion' THEN 1 END) as conversions,
COUNT(DISTINCT session_id) as sessions,
100.0 * COUNT(CASE WHEN event = 'conversion' THEN 1 END) /
COUNT(DISTINCT session_id) as conversion_rate_pct
FROM rum_events
WHERE timestamp > DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY effective_type;

Key Takeaways

  • Initialize web-vitals in your app's entry point before rendering React.
  • Send Core Web Vitals metrics to your backend with context (device, network, URL).
  • Add custom business metrics for flow duration, conversions, and errors.
  • Store RUM data in a database with proper indexing for time-series queries.
  • Use percentile analysis (P75, P50) to understand field data distribution.
  • Monitor trends: if P75 LCP rises, investigate and fix the regression.

Frequently Asked Questions

Does sending RUM data impact performance?

No. Use keepalive: true in the fetch request, which sends data in the background. The request completes even if the user closes the tab. Send metrics asynchronously; never await them in critical paths.

How much RUM data should I store?

Aggregate daily percentiles and trends in a data warehouse (BigQuery, Snowflake). Keep raw events for 7–30 days, then archive or delete. A high-traffic site (1M page views/day) generates ~3–5M RUM events/day—store at most 14 days of raw events.

How do I detect a performance regression with RUM?

Compare P75 LCP/INP/CLS week-over-week. If P75 LCP rises by > 10% or crosses a threshold (e.g., 2.5s), alert your team. Use SQL queries to identify which page routes, devices, or network types regressed.

Can I use a third-party RUM service instead of building my own?

Yes. Services like Datadog, New Relic, Sentry, or Elastic offer managed RUM (agent installed, backend managed). Trade-off: less control but less infrastructure to maintain.

Further Reading