Monitor real user Core Web Vitals data with a custom RUM implementation. This tutorial walks through building a production-ready RUM system.
Prerequisites
- Basic JavaScript knowledge
- Access to a web server or serverless platform
- Google Search Console access for validation
Step 1: Install the Web Vitals Library
npm install web-vitals
Step 2: Implement RUM Collection
// rum.js
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
const analyticsEndpoint = '/api/vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
url: window.location.href,
userAgent: navigator.userAgent,
connection: navigator.connection ? {
effectiveType: navigator.connection.effectiveType,
downlink: navigator.connection.downlink,
} : null,
timestamp: Date.now(),
});
// Use sendBeacon for reliability
if (navigator.sendBeacon) {
navigator.sendBeacon(analyticsEndpoint, body);
} else {
fetch(analyticsEndpoint, { body, method: 'POST', keepalive: true });
}
}
// Register all Core Web Vitals observers
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
Step 3: Server-Side Data Collection
// api/vitals.js (Vercel serverless function)
export default function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end();
const metric = req.body;
// Validate metric data
if (!metric.name || !metric.value) {
return res.status(400).json({ error: 'Invalid metric' });
}
// Store in database
await db.query(`
INSERT INTO web_vitals (
metric_name, value, rating, delta, page_url,
user_agent, connection_type, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
`, [
metric.name, metric.value, metric.rating,
metric.delta, metric.url,
metric.userAgent, metric.connection?.effectiveType
]);
res.status(200).json({ success: true });
}
Step 4: Build a Dashboard
SELECT
page_url,
metric_name,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75,
COUNT(*) as sample_size,
AVG(CASE WHEN rating = 'good' THEN 1 ELSE 0 END) as good_rate
FROM web_vitals
WHERE created_at > NOW() - INTERVAL '28 days'
GROUP BY page_url, metric_name
HAVING COUNT(*) > 50
ORDER BY p75 DESC;
Step 5: Set Up Alerts
// Alert when p75 exceeds thresholds
const THRESHOLDS = {
LCP: 2500,
INP: 200,
CLS: 0.1,
FCP: 1800,
TTFB: 800,
};
async function checkVitalsAlerts() {
for (const [metric, threshold] of Object.entries(THRESHOLDS)) {
const result = await getLatestP75(metric);
if (result.p75 > threshold) {
await sendAlert(`${metric} p75 (${result.p75}ms) exceeds threshold (${threshold}ms)`);
}
}
}
Step 6: Integrate with Search Console Data
Compare your RUM data with Google Search Console:
- Align URL patterns between RUM and CrUX
- Identify pages where lab and field data diverge
- Prioritize optimization based on traffic + poor vitals
Validation Checklist
- Web Vitals library loads and captures metrics
- Data reaches server endpoint
- Database stores metrics correctly
- Dashboard shows p75 values by page
- Alerts fire when thresholds exceeded
- RUM data aligns with Search Console field data
- Sample size is sufficient (100+ per page per 28 days)
- Attribution data helps identify LCP elements