Skip to content

Real User Monitoring — Performance Analytics with RUM

DodaTech Updated 2026-06-23 8 min read

In this tutorial, you will learn how to implement Real User Monitoring (RUM) to collect performance data from actual visitors, aggregate it into meaningful dashboards, and use it to identify regressions and optimization opportunities. Unlike lab tools, RUM captures the true user experience across devices, networks, and geographies. DodaTech uses a custom RUM pipeline to track Core Web Vitals across millions of sessions.

What You Will Learn

  • The difference between lab data and field data
  • How to collect performance metrics using the Performance API
  • How to build a RUM data pipeline from collection to dashboard
  • How to identify regressions and prioritize fixes based on real user data

Why It Matters

Lab tools like Lighthouse measure performance in a controlled environment. Real users have different devices, networks, and browser extensions. RUM reveals what users actually experience. DodaTech discovered that 30 percent of users had LCP over 4 seconds even though Lighthouse showed 1.8 seconds, because the lab tests did not account for real-world conditions.

Real-World Use Case

The Doda Browser team used RUM data to discover that users in Southeast Asia had significantly higher LCP due to a CDN edge gap. After adding edge nodes in Singapore and Mumbai, LCP for those regions dropped from 4.2 seconds to 1.9 seconds, a fix that lab testing alone would never have identified.

Prerequisites

You should understand Core Web Vitals metrics and how JavaScript event handling works. Familiarity with Google Analytics or similar analytics platforms is helpful.

Step-by-Step Tutorial

Step 1: Understand Lab vs Field Data

Lab data is collected in a controlled environment with a predefined device and network condition. Field data comes from real users under real conditions.

Aspect Lab (Lighthouse) Field (RUM)
Environment Controlled Real-world
Device Emulated Actual devices
Network Throttled (simulated) Real network conditions
Cache Cold by default Mixed (cold + warm)
Geographic Single location Global
Metrics LCP, TBT, CLS, SI LCP, FID, CLS, TTFB

RUM is essential for metrics that cannot be measured in a lab, such as First Input Delay (FID).

Step 2: Collect Metrics with the web-vitals Library

The web-vitals library provides a simple API for collecting Core Web Vitals in the browser.

<script type="module">
import {onLCP, onFID, onCLS, onTTFB, onFCP, onINP} from 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.js?module';

function sendToAnalytics(metric) {
  const body = {
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
    url: window.location.pathname,
    device: navigator.userAgentData?.mobile ? 'mobile' : 'desktop',
    connection: navigator.connection?.effectiveType || 'unknown'
  };

  // Send via sendBeacon for reliability
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/rum', JSON.stringify(body));
  } else {
    fetch('/api/rum', {method: 'POST', body: JSON.stringify(body), keepalive: true});
  }
}

onLCP(sendToAnalytics);
onFID(sendToAnalytics);
onCLS(sendToAnalytics);
onTTFB(sendToAnalytics);
onFCP(sendToAnalytics);
onINP(sendToAnalytics);
</script>

Expected output: The browser sends a POST request to /api/rum for each metric. The request includes the metric name, value, rating (good/needs-improvement/poor), and context information.

Step 3: Build the Server-Side Ingestion Pipeline

Create a server endpoint that receives RUM data and stores it in a time-series database.

// Express.js RUM ingestion endpoint
const express = require('express');
const app = express();
app.use(express.json());

// In-memory store (use InfluxDB, ClickHouse, or similar in production)
const metrics = [];

app.post('/api/rum', (req, res) => {
  const metric = {
    ...req.body,
    timestamp: new Date().toISOString(),
    userAgent: req.headers['user-agent']
  };
  metrics.push(metric);
  res.status(204).end();
});

// Aggregation endpoint
app.get('/api/rum/aggregate/:metric', (req, res) => {
  const metricName = req.params.metric;
  const window = parseInt(req.query.window) || 3600000; // Default 1 hour
  const cutoff = Date.now() - window;

  const filtered = metrics.filter(m =>
    m.name === metricName && new Date(m.timestamp).getTime() > cutoff
  );

  const values = filtered.map(m => m.value);
  const percentiles = (arr, p) => {
    arr.sort((a, b) => a - b);
    return arr[Math.floor(arr.length * p / 100)];
  };

  res.json({
    metric: metricName,
    count: values.length,
    p50: percentiles(values, 50),
    p75: percentiles(values, 75),
    p90: percentiles(values, 90),
    p95: percentiles(values, 95),
    p99: percentiles(values, 99),
    good: filtered.filter(m => m.rating === 'good').length,
    needsImprovement: filtered.filter(m => m.rating === 'needs-improvement').length,
    poor: filtered.filter(m => m.rating === 'poor').length
  });
});

app.listen(3001);

Expected output: Querying /api/rum/aggregate/LCP returns JSON with count, percentiles, and rating distribution for the last hour of data.

Step 4: Build a RUM Dashboard

Visualize the RUM data to track performance trends.

<!DOCTYPE html>
<html>
<head>
  <title>RUM Dashboard - DodaTech</title>
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
  <h1>Real User Monitoring</h1>
  <div class="grid">
    <div class="card">
      <h2>LCP (p75)</h2>
      <div id="lcp-chart"><canvas></canvas></div>
    </div>
    <div class="card">
      <h2>CLS (p75)</h2>
      <div id="cls-chart"><canvas></canvas></div>
    </div>
    <div class="card">
      <h2>Good / Needs Improvement / Poor</h2>
      <div id="rating-chart"><canvas></canvas></div>
    </div>
  </div>
  <script>
    // Fetch RUM data and render charts
    async function updateDashboard() {
      const lcp = await fetch('/api/rum/aggregate/LCP').then(r => r.json());
      const cls = await fetch('/api/rum/aggregate/CLS').then(r => r.json());
      document.getElementById('lcp-value').textContent = `${(lcp.p75 / 1000).toFixed(2)}s`;
      document.getElementById('cls-value').textContent = cls.p75.toFixed(3);
    }
    setInterval(updateDashboard, 60000);
    updateDashboard();
  </script>
</body>
</html>

Step 5: Set Up Alerting

Configure alerts to notify the team when metrics degrade.

// Alerting service
async function checkAlerts() {
  const metrics = ['LCP', 'FID', 'CLS', 'TTFB'];
  const thresholds = {
    LCP: { poor: 4000 },    // Alert if p75 LCP > 4s
    FID: { poor: 300 },
    CLS: { poor: 0.25 },
    TTFB: { poor: 1500 }
  };

  for (const metric of metrics) {
    const data = await fetch(`http://localhost:3001/api/rum/aggregate/${metric}`).then(r => r.json());
    if (data.p75 > thresholds[metric].poor) {
      console.error(`ALERT: ${metric} p75 (${data.p75}) exceeds poor threshold (${thresholds[metric].poor})`);
      // Send alert to PagerDuty, Slack, or email
    }
  }
}

setInterval(checkAlerts, 300000); // Check every 5 minutes

Step 6: Segment Data by Dimension

Break down RUM data by device type, connection speed, geography, and page to identify specific problem areas.

function sendToAnalytics(metric) {
  const body = {
    ...metric,
    page: window.location.pathname,
    device: navigator.userAgentData?.mobile ? 'mobile' : 'desktop',
    connection: navigator.connection?.effectiveType || 'unknown',
    country: 'unknown', // Set via server-side geo-IP lookup
    experiment: document.cookie.includes('experiment=A') ? 'A' : 'control'
  };
  navigator.sendBeacon('/api/rum', JSON.stringify(body));
}

Step 7: Use CrUX API for Free Field Data

The Chrome User Experience Report (CrUX) provides field data for any public URL. Use it to validate your own RUM data.

# Query CrUX API for a URL
curl -X POST "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$API_KEY" \
  -H "Content-Type: application/json" \
  --data '{"url": "https://dodatech.com", "formFactor": "PHONE"}'

Expected output: JSON data showing the distribution of LCP, FID, CLS across good/needs-improvement/poor for real Chrome users.

Step 8: Correlate RUM with Business Metrics

Track how performance changes affect conversion rates, bounce rates, and revenue.

async function correlatePerformance() {
  const rum = await fetch('/api/rum/aggregate/LCP').then(r => r.json());
  const revenue = await fetch('/api/revenue/daily').then(r => r.json());

  // Simple correlation: plot LCP p75 against daily revenue
  console.log(`LCP p75: ${rum.p75}ms, Revenue: $${revenue.today}`);
  // Expected: As LCP increases, revenue decreases
}

Learning Path

flowchart LR
  A[Performance Testing] --> B[Real User Monitoring]
  B --> C[Performance Budgets]
  B --> D[Third-Party Script Impact]
  C --> E[Continuous Improvement]
  
  style B fill:#4f46e5,color:#fff
  style A fill:#6366f1,color:#fff
  style C fill:#6366f1,color:#fff

Common Errors

  1. Sending RUM data synchronously: Synchronous XHR or fetch during page unload may be cancelled. Always use navigator.sendBeacon() for RUM data transmission.

  2. Collecting metrics on every interaction: Only collect metrics on initial page load and significant user interactions. Sampling 1-10 percent of visitors is often sufficient and reduces noise.

  3. Not filtering bot traffic: Automated bots and crawlers generate misleading RUM data. Filter by navigator.webdriver and user-agent patterns.

  4. Ignoring the cross-origin isolation issue: Some performance APIs require Cross-Origin-Embedder-Policy and Cross-Origin-Opener-Policy headers to provide precise timing data.

  5. Storing RUM data without aggregation: Raw RUM data grows quickly. Aggregate data by the minute or hour and store aggregates for long-term trending.

  6. Sampling inconsistently: If you sample 10 percent of users, ensure you sample consistently. Varying sample rates between page types skews comparisons.

Practice Questions

  1. What is the difference between RUM field data and Lighthouse lab data?
  2. Why is navigator.sendBeacon preferred for RUM data transmission?
  3. What is the CrUX API and how does it complement RUM?
  4. Why should you segment RUM data by device and connection type?
  5. How can you correlate performance data with business metrics?

Answers: 1. Lab data is collected in a controlled environment; field data comes from real users under real-world conditions including actual devices, networks, and locations. 2. sendBeacon guarantees delivery even during page unload, unlike fetch or XHR which may be cancelled. 3. The Chrome User Experience Report provides free field data from real Chrome users, validating your own RUM collection. 4. Performance varies significantly between mobile and desktop, and between 4G and 3G connections. Segmentation identifies specific problem cohorts. 5. By time-correlating performance metrics with conversion rates, bounce rates, or revenue data, you can quantify the business impact of performance.

Challenge

Implement a complete RUM pipeline for a sample site: add the web-vitals library, build a server-side ingestion endpoint, store the data in memory with hourly aggregation, create a dashboard showing p75 LCP and good/needs-improvement/poor distribution, and set up an alert that fires when the poor percentage exceeds 10 percent.

FAQ

What is a good RUM sample rate?

For high-traffic sites (over 100k daily visits), sample 1-5 percent of users. For smaller sites, sample 10-25 percent. Ensure the sample is statistically representative.

Does RUM affect page performance?

Adding RUM code can theoretically affect performance, but the impact is minimal (under 5ms) when using sendBeacon and lightweight measurement libraries like web-vitals.

How do I handle GDPR and privacy for RUM?

RUM data can include IP addresses and user agents. Anonymize IPs, do not store full user agents, and ensure your privacy policy discloses RUM collection. Consider using differential privacy techniques.

Can RUM measure interaction to next paint (INP)?

Yes, the web-vitals library version 4+ supports INP measurement. INP will replace FID as a Core Web Vital metric. Start collecting it now for a baseline.

What RUM tools does DodaTech use?

DodaTech built a custom RUM pipeline using the web-vitals library, a Node.js ingestion API, and ClickHouse for time-series storage. Dashboards are rendered using Grafana connected to the ClickHouse data source.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro