Skip to content
Blog

Observability for Web Apps: RUM and Error Tracking

A practical guide to real user monitoring and error tracking for web apps — measuring Core Web Vitals, capturing console, page, and network errors, and shipping batched telemetry to your own analytics backend.

Published on August 14, 2026

AI Assistant

Your Lighthouse score is 100, your staging is green, and then a customer emails you a screenshot of a blank page on their phone. You can’t reproduce it, there’s no server log for it, and the only person who saw the error was the user who hit it. This is the classic blind spot of server-side monitoring: everything that happens in the browser — layout shifts, slow interactions, unhandled promise rejections, network failures — is invisible to your backend.

Real User Monitoring (RUM) closes that gap by instrumenting the actual page and shipping telemetry to an analytics backend. In this tutorial, you will learn what RUM is (and how it differs from synthetic monitoring), how to measure the Core Web Vitals with the web-vitals library, how to capture errors and network problems, and how to build a small, self-contained RUM SDK that batches events with navigator.sendBeacon() to a server endpoint. Key technologies: web-vitals, the PerformanceObserver API, navigator.sendBeacon, and a small Node/Express collection endpoint.

Prerequisites

  • A web app you can deploy a <script> to (any framework works).
  • Node.js 18+ and npm for the example server.
  • A backend endpoint or analytics service that accepts POST /rum/collect.
  • Basic familiarity with the browser DevTools Console and Network panels.

You do not need a paid analytics product. Everything in this tutorial runs on a free tier or your own infrastructure.

RUM vs. synthetic monitoring

There are two fundamentally different ways to observe a web page:

  • Synthetic monitoring (Lighthouse, WebPageTest, uptime checks): a scripted browser loads the page in a controlled environment with throttled CPU/network. It is deterministic, fast, and great for debugging — but it measures your testing rig, not your users.
  • Real User Monitoring (RUM): passive instrumentation embedded in the page that records what every real visitor actually experiences on their own device and network.

RUM is the only source of truth for field experience. As the Chrome team puts it, lab measurement is not a substitute for field measurement — device capability, network conditions, and user interaction all shift scores, and only field data captures that. A healthy setup runs both: synthetic checks in CI to catch regressions before release, and RUM in production to tell you whether the release actually helped.

What data to capture

A minimal-but-useful RUM payload covers three categories:

  1. Web Vitals — LCP (loading), INP (interactivity), CLS (visual stability), plus supporting metrics like TTFB and FCP.
  2. Errors — uncaught exceptions, unhandled promise rejections, console.error calls, and HTTP failures from fetch/XMLHttpRequest.
  3. Network context — navigation timing (TTFB, DOM content loaded), failed resource loads, and a few request timings for slow APIs.

Every event should be tagged with url, a timestamp, and ideally a session/visit ID so you can reconstruct what a user did before a failure.

Measuring Core Web Vitals with web-vitals

The web-vitals library is a ~3KB (brotli’d) production wrapper around the underlying web APIs, and it reports values in the same way Chrome and CrUX do. Install it from npm:

npm install web-vitals

Then measure each Core Web Vital by passing a callback:

import { onCLS, onINP, onLCP } from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
    rating: metric.rating,
    page: location.href,
  });

  // sendBeacon works even while the page is unloading
  (navigator.sendBeacon && navigator.sendBeacon('/rum/collect', body)) ||
    fetch('/rum/collect', { body, method: 'POST', keepalive: true });
}

onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);

Each metric object includes name, value, delta, rating (good, needs-improvement, or poor), a stable id you can use to dedupe deltas, and the underlying entries for debugging. Note some behaviors:

  • onINP never fires if the user never interacts with the page.
  • onCLS and onINP are reported again when the page’s visibilityState becomes hidden.
  • All metrics are re-reported after a back/forward cache restore.

If you want a little more diagnostic power in production, import from web-vitals/attribution instead — the callback then receives attribution data such as attribution.largestShiftTarget, attribution.interactionTarget, and attribution.target, which tell you which element caused a poor score.

Measuring Web Vitals manually with PerformanceObserver

The library is the right choice for production, but understanding the underlying PerformanceObserver API helps you debug and extend it. Each Core Web Vital maps to a performance entry type:

// LCP — the largest contentful paint candidate
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('LCP:', entry.startTime, entry.element?.tagName, entry.url);
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });

// CLS — accumulate layout-shift scores, ignoring input-driven shifts
let cls = 0;
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) cls += entry.value;
  }
}).observe({ type: 'layout-shift', buffered: true });

// INP — slow interactions via event-timing entries
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 40) {
      console.log('INP candidate:', entry.name, entry.duration);
    }
  }
}).observe({ type: 'event', durationThreshold: 40 });

The buffered: true flag lets the observer replay entries that occurred before it was registered — which is why web-vitals can be loaded deferred rather than in the <head>. The default event-timing threshold is 40ms, which filters out the vast majority of trivial interactions.

Capturing errors: window.onerror and unhandledrejection

JavaScript errors split into two buckets: uncaught exceptions and unhandled promise rejections. Hook both:

window.addEventListener('error', (event) => {
  track('error', {
    message: event.message,
    source: event.filename,
    line: event.lineno,
    col: event.colno,
    stack: event.error?.stack,
  });
});

window.addEventListener('unhandledrejection', (event) => {
  track('unhandledrejection', {
    reason: String(event.reason),
    stack: event.reason?.stack,
  });
});

Important caveat: these hooks do not catch errors inside try/catch blocks or rejected promises that you handle with .catch(). For those, decide deliberately — either log-and-rethrow, or call track('error', ...) yourself at the catch site.

Capturing console.error and network failures

console.error is where most teams actually surface problems, but it only lives in DevTools unless you intercept it:

const originalError = console.error;
console.error = (...args) => {
  track('console.error', { message: args.map(String).join(' ') });
  originalError.apply(console, args);
};

For network health, two sources are cheap to observe. Resource timing gives you failed downloads:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'resource' && entry.transferSize === 0) {
      track('resource.failed', { url: entry.name });
    }
  }
}).observe({ type: 'resource', buffered: true });

And navigation timing exposes TTFB and document load metrics via the single navigation entry. If you want failed API calls specifically, wrap fetch once to record status codes and latencies:

const fetchWithTelemetry = async (url, init) => {
  const start = performance.now();
  try {
    const res = await fetch(url, init);
    if (res.status >= 400) track('http.error', { url, status: res.status, ms: performance.now() - start });
    return res;
  } catch (err) {
    track('http.failed', { url, ms: performance.now() - start });
    throw err;
  }
};

Batching payloads with navigator.sendBeacon

Sending one request per event is wasteful and unreliable — events fired on unload may never complete a normal fetch. navigator.sendBeacon() queues data to be sent reliably even while the page is closing. The web-vitals docs recommend exactly this pattern: accumulate reports in a queue and flush it when the page is backgrounded or unloaded.

const queue = [];

function track(type, fields) {
  queue.push({
    type,
    ...fields,
    url: location.href,
    ts: Date.now(),
    ua: navigator.userAgent,
  });
}

function flush() {
  if (queue.length === 0) return;
  const body = new Blob([JSON.stringify(queue)], { type: 'application/json' });
  navigator.sendBeacon('/rum/collect', body);
  queue.length = 0;
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});

window.addEventListener('pagehide', flush);

Why visibilitychange/pagehide instead of beforeunload? The Page Lifecycle API docs recommend them: beforeunload is unreliable and hurts performance, while visibilitychange to hidden is guaranteed to fire and covers tab switches, mobile app backgrounding, and page closes. A Blob is used instead of a plain string so the request sends with the correct Content-Type: application/json.

Keeping privacy in mind

RUM data is personal data. Before you ship, redact PII at the source — never let emails, tokens, passwords, or card numbers reach your backend:

const PII_KEY = /email|password|token|secret|card|phone|authorization|ssn/i;

function redact(obj) {
  return JSON.parse(
    JSON.stringify(obj, (key, value) => (PII_KEY.test(key) ? '[REDACTED]' : value))
  );
}

Beyond redaction: never log full stack traces that embed query-string credentials, avoid capturing the entire URL if it contains user identifiers, add a consent gate if your users are in regions that require it, and set a retention policy on the collection endpoint. web-vitals itself reports nothing identifiable by default — the metric object has no user data — but your own track() calls can easily leak it.

Putting It All Together

Here is a complete, runnable RUM SDK — a single file you can load as a module on any page, followed by the minimal Node/Express endpoint that receives the batches.

// rum.js — drop on every page as <script type="module" src="/rum.js">
import { onCLS, onINP, onLCP } from 'web-vitals';

const ENDPOINT = '/rum/collect';
const PII_KEY = /email|password|token|secret|card|phone|authorization|ssn/i;
const queue = [];

function redact(obj) {
  return JSON.parse(JSON.stringify(obj, (key, value) => (PII_KEY.test(key) ? '[REDACTED]' : value)));
}

function track(type, fields) {
  queue.push(redact({ type, ...fields, url: location.href, ts: Date.now() }));
}

function flush() {
  if (queue.length === 0) return;
  navigator.sendBeacon(ENDPOINT, new Blob([JSON.stringify(queue)], { type: 'application/json' }));
  queue.length = 0;
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});
window.addEventListener('pagehide', flush);

// Web Vitals
const sendMetric = (metric) => track('vital', {
  name: metric.name, value: metric.value, id: metric.id, rating: metric.rating,
});
onCLS(sendMetric);
onINP(sendMetric);
onLCP(sendMetric);

// Errors
window.addEventListener('error', (e) => track('error', {
  message: e.message, source: e.filename, line: e.lineno, stack: e.error?.stack,
}));
window.addEventListener('unhandledrejection', (e) => track('unhandledrejection', {
  reason: String(e.reason), stack: e.reason?.stack,
}));

// Network
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'resource' && entry.transferSize === 0) {
      track('resource.failed', { url: entry.name });
    }
  }
}).observe({ type: 'resource', buffered: true });
// server.js — minimal collection endpoint
import express from 'express';

const app = express();
app.use(express.json({ limit: '1mb' }));

app.post('/rum/collect', (req, res) => {
  const events = Array.isArray(req.body) ? req.body : [req.body];
  for (const event of events) {
    // In practice: insert into a table or forward to your analytics pipeline.
    console.log(JSON.stringify(event));
  }
  res.status(204).end(); // 204 tells the beacon the batch was accepted
});

app.listen(4242, () => console.log('RUM collector on :4242'));

Expected output — when you load a page with the SDK and press F5, the server prints one batch per page transition, e.g.:

[{"type":"vital","name":"LCP","value":1420.4,"id":"v2-...","rating":"good","url":"https://app.example.com/","ts":1784110400000},
 {"type":"vital","name":"INP","value":86,"id":"v2-...","rating":"good","url":"https://app.example.com/","ts":1784110400000},
 {"type":"vital","name":"CLS","value":0.002,"id":"v2-...","rating":"good","url":"https://app.example.com/","ts":1784110400000}]

The id fields let your analytics layer dedupe or group deltas; the rating fields map directly to the “good / needs-improvement / poor” buckets Google grades on at the 75th percentile.

Conclusion & Next Steps

You now know what RUM is versus synthetic monitoring, how to measure LCP/INP/CLS with both web-vitals and raw PerformanceObserver, how to capture uncaught errors, rejections, and failed resources, and how to batch everything through navigator.sendBeacon into your own collection endpoint. Next steps: add web-vitals/attribution for element-level diagnostics, alert on your 75th-percentile values (INP > 200ms, LCP > 2.5s, CLS > 0.1) rather than averages, and instrument real user journeys so you can correlate a failed payment call with the interaction that preceded it.

References / Sources