Performance

In-house RUM without a service: what it really takes

One beacon weighs 239 bytes and twenty samples in a batch fall to 273 bytes with gzip. PerformanceObserver and sendBeacon are enough, no service.

Key points

  • One RUM sample in JSON weighs 239 bytes: twenty samples in a batch reach 4,807 bytes.
  • With gzip the same batch falls to 273 bytes, 17.6 times less, so the network cost is negligible.
  • PerformanceObserver plus sendBeacon on pagehide is enough: no external service, no dependency.
  • The number to publish is the 75th percentile, not the mean: the mean hides the slow tail.

In-house RUM runs on two browser APIs, PerformanceObserver and navigator.sendBeacon, plus an endpoint that accepts JSON. Network cost is not the problem: one sample weighs 239 bytes, and twenty samples in a compressed batch fit in 273 bytes.

What to collect, and when

MetricAPIMomentWhat it answers
TTFBnavigation.responseStartpagehidehow long the server takes
LCPPerformanceObserver largest-contentful-paintpagehidewhen the main content appears
CLSPerformanceObserver layout-shiftpagehidehow much the page moves
INPPerformanceObserver event, interactionIdpagehidehow fast it answers input

The minimum collector

const q = [];
const push = (kind, value) => q.push({ kind, value, path: location.pathname });
new PerformanceObserver((l) => {
  for (const e of l.getEntries()) push("lcp", Math.round(e.startTime));
}).observe({ type: "largest-contentful-paint", buffered: true });
new PerformanceObserver((l) => {
  for (const e of l.getEntries()) if (!e.hadRecentInput) push("cls", +e.value.toFixed(4));
}).observe({ type: "layout-shift", buffered: true });
addEventListener("pagehide", () => {
  const nav = performance.getEntriesByType("navigation")[0];
  push("ttfb", Math.round(nav.responseStart));
  navigator.sendBeacon("/rum", new Blob([JSON.stringify(q)], { type: "application/json" }));
});

The code needs a /rum endpoint that accepts POST JSON, and a browser with sendBeacon. The advantage of sendBeacon is that the request survives page closure, where fetch would be cancelled. The pagehide event also covers the move into bfcache, where unload never fires.

What it really weighs

node -e "const z=require('zlib');const b=JSON.stringify(Array.from({length:20},(_,i)=>({kind:'lcp',value:1200+i*20})));console.log(Buffer.byteLength(b),z.gzipSync(b).length)"

The command needs Node and no dependency. On a real batch the values change with the number of samples, but the order of magnitude holds: a few kilobytes per visit, reduced to a few hundred bytes once the server compresses the request. If your endpoint does not compress, add Content-Encoding: gzip and accept the compressed body: the saving is a factor of 17.

From sample to a publishable number

A RUM tool that publishes the mean is lying: the mean hides the slow tail. You sort the sample per metric and take the value at the 75th percentile, the number CrUX and Search Console use. With twenty samples the 75th percentile is the fifteenth value in ascending order, which is far too noisy for a decision: you need enough visits, otherwise read the data as a hint and not a verdict.

The limitation to name

People who block scripts send nothing, so the sample skews towards clean browsers on decent connections. Metrics lost to a crash or a hard close never arrive. And a beacon carrying the path is personal data: you need a legal basis, field minimisation and short retention, not an eternal spreadsheet.

Where to continue

The EneUI catalogue indexes 111,985 components, the banner 01 block and the chart bar icon, the one that ends up in the panel. For the mechanics of the observers read PerformanceObserver in practice; for lab against field, measuring LCP; for the thresholds, what Core Web Vitals are; and for the comparison between architectures, frontend frameworks compared.

Frequently asked questions

Do I need an external service for RUM?

No. PerformanceObserver collects the metrics in the browser and sendBeacon posts them to your endpoint. An external service adds dashboards and comparisons you can rebuild with one query.

Why send on pagehide and not on unload?

Because unload never fires when the page enters bfcache, and many mobile browsers skip it. pagehide covers both cases, and sendBeacon survives the close.

How many samples make a 75th percentile reliable?

At least a few hundred visits per page and device type. With twenty samples the percentile jumps by hundreds of milliseconds and cannot support a decision.

Does the RUM beacon slow the page down?

Not if it fires on pagehide and stays under a compressed kilobyte. The real cost sits on the server: one row per visit, and percentiles aggregated by a scheduled query.

How do I handle personal data in the beacon?

Collect only what you need: path, device type, metrics. No stable identifiers, short retention, and a line in the privacy notice. A path can identify a person on rare pages.