---
title: 'Real User Monitoring'
source: 'https://academia.sh/en/courses/frontend-quality/real-user-monitoring'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# Real User Monitoring

The different questions answered by lab measurement and field data, how metrics are collected and sent in the field, reading a distribution with percentile computation, and the effect of segmentation and sample size.

A stable test suite says that expected behavior is preserved in a controlled environment.
What it does not say is how the application behaves in the hands of real users. The test
machine is fast, its network is clean, its cache is in a known state. An observer using the
North Slope interface in the field, by contrast, works over a weak connection, with an
aging device, and other tabs open in the background.

This lesson places the two data sources side by side and makes the difference between them
measurable.

## Two Sources, Two Questions

**Lab data** comes from a controlled run: a fixed device profile, fixed network conditions,
a clean cache. The measurements in the performance topic are of this kind. Its valuable
property is that it is deterministic — the difference between two runs can be attributed to
a change made to the code. The question it answers is: what did this change break?

**Field data** is measurements collected from real sessions; the collection method is called
**real user monitoring**. It gives not a single number but a distribution. The question it
answers is different: what do users actually experience?

Neither substitutes for the other. Lab measurement is used for regression detection, field
data for setting targets.

## What Gets Collected in the Field

The browser accumulates metrics on its own and exposes them through an observer. The
following code collects paint entries and interaction entries that exceed the threshold, and
sends them as a single bundle when the page is hidden.

```js
// metrics-collector.js — collects field metrics and sends them as one bundle
const bundle = { path: location.pathname, version: '2.4.1', metrics: {} };

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    bundle.metrics[entry.name] = Math.round(entry.startTime ?? entry.duration);
  }
});
observer.observe({ type: 'paint', buffered: true });
observer.observe({ type: 'event', buffered: true, durationThreshold: 40 });

addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden') return;
  observer.takeRecords();
  navigator.sendBeacon('/metrics', JSON.stringify(bundle));
}, { once: true });
```

Three decisions are embedded in this code. The `buffered` option also retrieves entries that
occurred before the observer was set up, so the first paint is not lost when the measurement
script loads late. Sending happens not at page close but when the page is **hidden**: a tab
can be pushed to the background without closing and never come back to the foreground, in
which case the close event never fires at all. Sending is done with a beacon request that
also completes while the page is being unloaded.

Context fields are added to the bundle: path, application version, device class, connection
type, navigation type. Segmentation is done with these fields, and, as the next section will
show, what a single number hides is revealed here.

Collection does not have to happen on every session. **Sampling** is taking data from a
portion of sessions; lowering the rate lowers transmission and storage cost. The selection
must be made once per session — if it is made per page, some pages of the same session enter
the sample and others do not, and session-level questions go unanswered.

## Reading the Distribution

Field data is a distribution, and there is more than one way to reduce a distribution to a
single number. The following module produces a deterministic field sample — three device
classes, with different shares and different duration ranges — and gives the percentile
computation.

```js
// field-data.mjs — deterministic simulation of field measurements and percentile computation
export function generator(seed) {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

export const CLASSES = [
  { name: 'fast', share: 0.60, base: 300, spread: 200 },
  { name: 'mid', share: 0.30, base: 700, spread: 600 },
  { name: 'slow', share: 0.10, base: 1800, spread: 2200 },
];

export function fieldSample(count, seed = 42) {
  const random = generator(seed);
  const samples = [];
  for (let i = 0; i < count; i += 1) {
    const pick = random();
    let cumulative = 0;
    const chosen = CLASSES.find((c) => (cumulative += c.share) >= pick) ?? CLASSES.at(-1);
    samples.push({ class: chosen.name, duration: Math.round(chosen.base + chosen.spread * random()) });
  }
  return samples;
}

// nearest-rank method: the share of samples at or below the p percentile is at least p
export function percentile(values, p) {
  if (values.length === 0) return NaN;
  const sorted = [...values].sort((a, b) => a - b);
  const rank = Math.ceil((p / 100) * sorted.length);
  return sorted[Math.min(sorted.length, Math.max(1, rank)) - 1];
}

export function average(values) {
  return Math.round(values.reduce((t, d) => t + d, 0) / values.length);
}
```

```js
// report.mjs — comparing lab measurement with the field distribution
import { fieldSample, percentile, average, CLASSES } from './field-data.mjs';

const field = fieldSample(5000);
const durations = field.map((o) => o.duration);

// lab run: fast device, stable network, middle of the distribution
const fast = CLASSES[0];
const lab = Math.round(fast.base + fast.spread * 0.5);

console.log(`lab measurement      ${String(lab).padStart(5)} ms`);
console.log(`field average        ${String(average(durations)).padStart(5)} ms`);
for (const p of [50, 75, 90, 95]) {
  console.log(`field p${String(p).padEnd(2)}             ${String(percentile(durations, p)).padStart(5)} ms`);
}

console.log('\np75 by device class');
for (const c of CLASSES) {
  const subset = field.filter((o) => o.class === c.name).map((o) => o.duration);
  console.log(`  ${c.name.padEnd(6)} ${String(subset.length).padStart(4)} sessions   p75 = ${percentile(subset, 75)} ms`);
}

console.log('\nsample size and p75 stability');
for (const n of [20, 100, 1000, 5000]) {
  const p75s = [0, 1, 2, 3, 4].map((t) => percentile(fieldSample(n, 100 + t).map((o) => o.duration), 75));
  console.log(`  n=${String(n).padStart(4)}  ${p75s.join('  ')}`);
}
```

```sh
node report.mjs
```

```
lab measurement        400 ms
field average          848 ms
field p50               471 ms
field p75              1014 ms
field p90              1853 ms
field p95              2971 ms

p75 by device class
  fast   2918 sessions   p75 = 452 ms
  mid    1561 sessions   p75 = 1143 ms
  slow    521 sessions   p75 = 3473 ms

sample size and p75 stability
  n=  20  775  930  837  1073  900
  n= 100  1054  1027  967  1082  1049
  n=1000  978  968  1006  1017  986
  n=5000  985  1001  996  1016  992
```

## What the Numbers Say

The lab measurement is 400 milliseconds. The field distribution's median is 471, its 75th
percentile (p75) 1014. So the controlled run represents the better half of users and shows
nothing of the reality of the remaining half. This does not mean the lab measurement is
wrong; it means its coverage is narrow.

The average is 848 milliseconds, and it describes no group of users. When a distribution has
a long tail, the average is pulled up by a minority of very slow sessions; the result is
neither the majority's experience nor a measure of the worst case. In duration
distributions, the average is not read.

Choosing a percentile is a trade-off. The median hides half the users. p95 and above start
to move with a handful of outlier sessions — a tab left in the background, an overloaded
device — and are noisy. **p75** sits between the two: it gives the value that three quarters
of users fall under, and is sensitive enough to the tail. Field metrics are therefore
commonly read from the 75th percentile.

The segmentation table shows what the single number hides. The same application's p75 is
452 milliseconds on fast devices, 1143 on the mid class, 3473 on the slow class. The overall
value of 1014 corresponds to none of these three realities. An improvement decision can only
be made with segments: the 521 sessions in the slow class carry more gain than any effort
spent trying to fix the overall average.

The last table is the effect of sample size. The p75 values of five separate twenty-session
samples range between 775 and 1073 — despite coming from the same distribution. At a
thousand sessions, the range narrows to between 968 and 1017. An "improvement" measured with
a small sample is, most of the time, only sample noise. Splitting a metric into segments
also splits the sample size; each segment must have enough sessions on its own.

## Tying It to the Budget

The budget defined in the performance topic gains meaning once it is verified with field
data. The budget check can be turned into a test.

```js
// budget.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fieldSample, percentile } from './field-data.mjs';

const BUDGET = { p75: 1200, slowClassP75: 4000 };

test('overall p75 is under budget', () => {
  const durations = fieldSample(5000).map((o) => o.duration);
  assert.ok(percentile(durations, 75) <= BUDGET.p75, `p75 = ${percentile(durations, 75)} ms`);
});

test('the slow device class is under its own budget', () => {
  const slow = fieldSample(5000).filter((o) => o.class === 'slow').map((o) => o.duration);
  assert.ok(slow.length >= 300, 'not enough samples');
  assert.ok(percentile(slow, 75) <= BUDGET.slowClassP75, `slow p75 = ${percentile(slow, 75)} ms`);
});
```

```sh
node --test budget.test.mjs
```

```
✔ overall p75 is under budget (2.774917ms)
✔ the slow device class is under its own budget (0.715333ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 33.845541
```

The two tests having separate budgets is deliberate. A single overall threshold could let a
regression in the slow class be masked by an improvement in the fast class. A budget per
segment prevents this trade-off.

The scope of the collected data is also a decision. The metrics bundle must not carry a
field that identifies the user; path information is stripped of any identifying segments,
and free text is never sent. The measurement code itself also has a cost — the observer
callbacks run on the main thread and slow down, by some amount, the very thing they measure.

## Summary

- Lab data is deterministic and serves regression detection; field data is a distribution
  and tells what users actually experience.
- Metrics are collected with the browser's observer and sent as a single bundle when the
  page is hidden; the sampling decision is made once per session.
- In duration distributions, the average is not read; the median hides half, very high
  percentiles are noisy, and the 75th percentile sits between the two.
- While the lab measurement was 400 milliseconds, the field p75 came out to 1014; once split
  into segments, the same metric became 452, 1143, and 3473 milliseconds.
- In a small sample, p75 ranges widely; a budget per segment prevents a regression in one
  class from being masked by an improvement in another.

## Next Step

Metrics say how fast the application is; they do not say whether it works. If an observer
throws an exception while trying to record a measurement, that session's duration metrics
keep looking perfectly fine. The user, meanwhile, is looking at a blank screen and, most of
the time, tells no one. The last lesson covers the mechanism that makes these silent
failures visible: catching and sending client errors, and reading thousands of events not
one by one but in groups that trace back to the same origin.
