#Next.js#React

Web Performance Monitoring (RUM): From Collection to Alerts

Lighthouse only runs during development, but who watches real users in production? RUM (Real User Monitoring) collects real-user LCP, INP, CLS, and JavaScript errors. This article covers collection with the web-vitals library, reporting, dashboards, and alerts, turning "fast in the lab" into "fast for users."

Key Points

  • Topic Focus: Next.js、React
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

I have seen too many teams work like this: run Lighthouse locally, get a score of 95, merge the PR, deploy, and celebrate. Three days later, customer support gets a complaint that "the page has frozen." You investigate and find normal server CPU, normal API P99 latency, and clean error logs. Everything is "normal," yet the experience is still slow for the user.

The problem is not that you lack monitoring. The problem is that you monitor the server and yourself, not your users. Lighthouse is a laboratory instrument. It simulates one ideal load on a clean machine. Real users, meanwhile, are on the subway, using budget phones, keeping 30 tabs open, and running ad-related browser extensions. Only data collected from users' browsers can close the gap between those two worlds. That is RUM (Real User Monitoring).

This article explains the complete RUM pipeline from collection to alerting and lists the pitfalls that actually appear in production.

1. Lighthouse Measures "How Fast It Can Be"; RUM Measures "How Fast It Actually Is"

The value of RUM comes down to one sentence: it collects performance data from real users' browsers. Whenever a user visits your page, their browser measures the actual LCP in milliseconds, how sluggish the INP is, and whether CLS caused the layout to jump, then sends those values back to your server. Once aggregated, the result is the real distribution "in the field," not an ideal curve from a controlled environment.

Why is RUM essential? There are three reasons, and Lighthouse cannot give you any of them:

First, you cannot simulate your users' devices. Even after throttling your Mac by 4×, it still hydrates React faster than a three-year-old budget Android phone. RUM makes the pain of low-end devices visible for the first time.

Second, you cannot simulate your users' networks. The "4G" simulated by Lighthouse is an ideal 4G connection at 9Mbps / 170ms RTT. Real-world 4G can fall from 1Mbps to no connection at all within a second. The gap between simulated and real values is often greater than 3×.

Third, you cannot predict user behavior. Lighthouse stops after measuring the load, but users immediately scroll, hammer buttons, and interact before the page is ready. You never measured any of those INP events.

In short, Lighthouse is a development-time health check, while RUM is 24-hour production monitoring. You need both, but only the latter can tell you whether users are suffering right now.

2. Collection: Use the web-vitals Library to Turn Browsers into Sensors

Google's officially maintained web-vitals library is the de facto standard. It exposes several callbacks. Each time the browser measures a metric, it invokes the corresponding callback, and you decide what to do with the resulting value.

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

const sessionId = crypto.randomUUID(); // Correlate multiple metrics from the same session

function report(metric) {
  // metric.name = 'LCP' | 'INP' | 'CLS' | ...
  // metric.value = Value (LCP/INP are milliseconds; CLS is a dimensionless score)
  // metric.id = Unique ID for this measurement (the same metric may be reported multiple times, such as INP)
  // metric.rating = 'good' | 'needs-improvement' | 'poor'
  const body = {
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    id: metric.id,
    sessionId,
    url: location.pathname, // Use pathname only; do not include queries or sensitive parameters
    ts: Date.now(),
  };
  send(body);
}

onLCP(report);
onINP(report);
onCLS(report);
onTTFB(report);
onFCP(report);

Several details are easy to get wrong:

LCP/FCP/TTFB are reported only once per page because they are load-time metrics, but CLS and INP may invoke their callbacks multiple times. CLS accumulates throughout the page lifecycle and can still change before the user leaves. INP is the worst interaction latency across all page interactions, and each new interaction may update it. Include the metric.id supplied by web-vitals, deduplicate on the backend by (sessionId, metric.name, metric.id), and keep only the latest value. Do not insert every callback as a new record, or one user's LCP will appear as five entries on the dashboard.

Next.js provides a ready-made hook. In the App Router, next/web-vitals exposes the same data through useReportWebVitals, so you do not need to install the library yourself:

tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';

export function WebVitalsReporter() {
  useReportWebVitals((metric) => {
    send(makeBody(metric));
  });
  return null;
}

Mount it in the root layout to collect data from every route.

3. Reporting: Use sendBeacon So Data Is Not Lost During Unload

Once you have collected the data, how do you send it back? This is where RUM implementations most commonly fail. Performance data has a special property: it often reaches its final value only when the page is about to close because CLS and INP depend on the full lifecycle. When the user closes the page, however, the browser may terminate your fetch request immediately. The page is gone before the TCP handshake finishes, and the data disappears.

There are two solutions, in priority order:

navigator.sendBeacon(url, data): this browser API was designed specifically for "reporting while the page unloads." It adds the request to the browser's event queue, and the browser ensures that it is sent even after the page has closed. Note that it sends POST requests with a blob/arrayBuffer, so the backend must accept that format.

fetch(url, { keepalive: true }): this works similarly to sendBeacon but supports arbitrary methods such as GET or POST, custom headers, and JSON payloads. It is more flexible, and many libraries, including newer versions of web-vitals, now use this approach internally.

A production-ready send function should look like this:

ts
function send(body: object) {
  const payload = JSON.stringify(body);
  const url = '/api/rum';

  // Prefer sendBeacon: it can still send while the page unloads
  if (navigator.sendBeacon) {
    const blob = new Blob([payload], { type: 'application/json' });
    if (navigator.sendBeacon(url, blob)) return;
  }

  // Fall back to fetch keepalive
  fetch(url, {
    method: 'POST',
    body: payload,
    headers: { 'Content-Type': 'application/json' },
    keepalive: true,
    credentials: 'omit',
  }).catch(() => {/* Fail silently; never affect the user */});
}

Several antipatterns deserve explicit mention: do not report from the unload event because many browsers never fire unload in bfcache scenarios, causing all the data to be lost; use visibilitychange + pagehide. Do not use synchronous XHR because modern browsers block it. Do not make reporting heavy with encryption, compression, or batches of 50 records. Those are all overengineering. A JSON payload of a few dozen bytes can go directly through sendBeacon at almost no real cost.

Possible destinations include Google Analytics 4 (GA4 is free but uses sampling and has weak dashboards), Sentry Performance (works out of the box and correlates errors well), Datadog/New Relic (expensive but enterprise-grade), or a self-hosted setup: expose a lightweight endpoint that writes to ClickHouse / Postgres and build your own Grafana dashboards. My preference is to run a self-hosted pipeline alongside Sentry: the former stores raw data for segmented analysis, while Sentry provides dashboards and alerts.

4. Reported Data: What Should One Data Point Look Like?

Reporting only a number is useless. Every data point must include context, or the dashboard will contain nothing but averages and give you no way to locate problems. This is the minimum viable set:

ts
interface RumEvent {
  name: 'LCP' | 'INP' | 'CLS' | 'TTFB' | 'FCP';
  value: number;
  rating: 'good' | 'needs-improvement' | 'poor';
  sessionId: string;     // Correlate multiple metrics from the same browsing session
  url: string;           // location.pathname, without the query
  device: 'mobile' | 'tablet' | 'desktop';  // Determine from UA or matchMedia
  connection: '4g' | '3g' | 'wifi' | 'slow-2g'; // navigator.connection.effectiveType
  route?: string;        // Current logical SPA route, not the physical URL
  release: string;       // Current frontend version; essential for alerts and attribution
  ts: number;
}

Three fields are especially important and commonly omitted by beginners:

sessionId: one user session may load multiple pages and produce multiple interactions. A session ID ties them together, allowing the backend to identify relationships such as "the same user had slow LCP on the homepage and slow INP on the detail page" and to perform session-level aggregation. Store a UUID in sessionStorage. It survives page refreshes but disappears when the tab closes, which matches the definition of "one session" perfectly.

release (version number): without this field, alerts are a dead end. You know only that "p75 LCP increased," not which version caused it. Include the release and the dashboard can draw a separate p75 LCP line for each version, making regressions in new versions immediately visible.

device and connection: these are the core dimensions for dashboard segmentation. "P75 LCP is 4.2s on mobile 4G and 1.6s on desktop Wi-Fi." That one sentence contains enough information to guide the next week of optimization. navigator.connection.effectiveType is experimental, but Chrome supports it across platforms. Safari and Firefox cannot provide it, so report unknown and do not force the issue.

JavaScript errors should use the same pipeline. Report exceptions captured by window.onerror + unhandledrejection with the same sessionId/url/release/device fields. Store performance data and errors in one table so that later analysis of "error rate and LCP spiked together" does not require another JOIN.

5. Dashboards: Look at p75, Never the Average

Once you have RUM data, the first rule is to use percentiles (p75 / p95), not averages.

Why are averages misleading? Performance data has a long-tailed distribution. Among 100 users, 95 may have an LCP of 1.5s while five unlucky users experience 12s. The average is 2s and looks fine, but those five users have already left. The average gets pulled by the long tail, simultaneously obscuring the majority's experience and diluting the minority's pain.

Google's standard is p75: CrUX reports, Search Console, and ranking signals all use p75. P75 LCP < 2.5s is considered "good." Align your internal dashboard with that definition so you and the SEO team are not talking past each other.

At minimum, the dashboard needs these views:

  • Global overview: p75 / p95 time series for every Core Web Vital, overlaid with release markers (draw vertical lines labeled with release version numbers on the chart).
  • By-page segmentation: rank the top 20 pages by p75 LCP/INP/CLS. Optimize the worst pages first.
  • Device x network segmentation: show p75 for combinations such as mobile + 4g and desktop + wifi, exposing real-world slowness.
  • Trend comparison: this week vs last week, and this release vs the previous release. Absolute values drift as the user population changes; version-to-version comparisons reveal whether a change actually worked.

6. Alerts: Sudden Changes Are More Dangerous Than Absolute Values

Alert design has one central rule: you need both absolute-threshold alerts and sudden-trend-change alerts.

An absolute threshold is the floor: alert when p75 LCP remains above 4s for one hour. That means some group of users already has a very poor experience. But absolute thresholds allow gradual deterioration to go unnoticed until the metric reaches the boundary.

A sudden-change alert is more sensitive: alert when p75 LCP rises more than 30% relative to the previous 24-hour baseline. This rule is especially powerful because it maps precisely to the most common failure mode: "a newly released version caused a performance regression."

Here are several alert rules I have used in production. You can apply them directly:

  1. p75 LCP rises > 30% period over period for 15 minutes → A new release probably introduced a large resource or regression.
  2. CLS p75 suddenly exceeds 0.25 → The layout is unstable, usually because of dynamically injected DOM elements such as ads, banners, or font fallbacks.
  3. JS error rate > 1% (or increases 5× period over period) → A classic broken release.
  4. TTFB p75 > 1.5s → This is a backend/CDN problem, not a frontend problem. Escalate it immediately.
  5. Sample count for a release is < 50% of the previous release → The reporting pipeline may be down, and the data may be misleading you.

Many people overlook rule 5. RUM data can fail silently: the reporting endpoint goes down, CORS is misconfigured, or an SDK error gets swallowed by try/catch. You receive no alert; the numbers on the dashboard simply shrink. Always monitor reporting volume itself. A drop in data volume reveals problems earlier than degraded metrics do.

7. Combine RUM with Backend Traces: Where Is the User's Time Going?

RUM tells you that "the experience feels slow," but it does not directly tell you why. If LCP is 4 seconds, did TTFB consume 2.5 seconds because of a backend/CDN problem, did the image take 1.8 seconds to download because of a frontend resource problem, or did hydration block the page for 1.2 seconds because of frontend JS? RUM provides the total; traces provide the breakdown.

The web-vitals metrics contain an implicit breakdown: TTFB is server response time, FCP - TTFB is the time from receiving the HTML/first byte to the first paint, and LCP - FCP is the time spent rendering the main content. Report all of these differences and chart them on the dashboard to make the slow segment immediately obvious.

Go further by correlating frontend RUM with backend distributed traces. Include a traceId in each report, injected from the server-timing header or the HTML, and you can match the backend trace to that LCP event. Sentry, Datadog, and OpenTelemetry all support this frontend-to-backend connection. When investigating, open a slow LCP sample and jump directly to the backend trace to see which SQL query was slow or which downstream service timed out. Anyone who has not connected traces cannot imagine how much faster this makes diagnosis.

One common mistake is focusing only on the frontend. I have seen many teams react to slow LCP in RUM by cutting the JS bundle, spend two weeks with no improvement, and finally discover that an N+1 query had pushed TTFB to 2 seconds. Let the data speak; do not let bias dictate the conclusion.

8. A Real Scenario: One Release Doubled LCP

Here is a true story. Last year, a team released a small update that added a rich-text editor component. CI was entirely green, Lighthouse still scored 92, and the weekly meeting celebrated the release. At the next morning's stand-up, the RUM dashboard showed p75 LCP jumping directly from 2.1s to 4.3s, while mobile 4G users went from 3.8s to 7.2s.

Segmenting by release made the new version vs the old version immediately obvious. Inspecting slow samples revealed that the new editor had inserted a 380KB JS bundle into the initial load. The list page did not need it at all, but it had been imported at the top level. The LCP timeline showed that the main image had finished downloading long before, but the editor's JS occupied the main thread and prevented the LCP element from painting.

The team rolled back in 10 minutes, and p75 LCP immediately returned to 2.2s. They later changed the editor to use dynamic import + route-level code splitting, released it again, and the numbers remained stable.

Without RUM, the story would have unfolded like this: two weeks of accumulating complaints, a customer support escalation, screenshots and questions from the boss, three days of engineering guesswork, and a search through the git log until everyone's eyes hurt before somebody finally found that import. The time from "deployment" to "diagnosis" could have been seven days; RUM reduced it to one hour. That is RUM's real value: it does not make the website faster; it ensures that a slowdown is discovered immediately.

Summary

  • Lighthouse is a development-time health check; RUM is production-time monitoring. The former tells you how fast the site can be, while the latter tells you how fast it actually is for users. You need both, but only RUM makes the pain of real-world users visible.
  • Collect with web-vitals and report with sendBeacon + fetch keepalive, triggered by visibilitychange rather than unload. INP/CLS may invoke callbacks multiple times, so deduplicate by metric.id.
  • Data points must include sessionId / release / device / connection, or the dashboard will contain only averages and reveal nothing useful. Send JS errors through the same pipeline.
  • Use p75 on dashboards, not averages; segment by page, device, and network; version-to-version comparisons are more meaningful than absolute values.
  • Alert on both absolute thresholds and sudden changes. A rule such as a 30% period-over-period rise in p75 LCP is the earliest radar for regressions in a new release. Monitor reporting volume itself as well because data volume can fall before metrics deteriorate.
  • Only RUM combined with traces can answer "which segment is slow?" Do not assume the frontend is responsible. Let the data identify TTFB, the main thread, or resource downloads.

One final sentence: "Deployment is not the end; it is the beginning of observation." If you do not monitor real users' experience in production, you are effectively throwing your website into a black box and hearing none of the screams from inside. RUM is the ear you place inside that box.

Related

Related Content