Tutorial: Setting Up Real User Monitoring (RUM) for Core Web Vitals

Monitor real user Core Web Vitals data with a custom RUM implementation. This tutorial walks through building a production-ready RUM system.

Prerequisites

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:

Validation Checklist