Batching Beacons With sendBeacon

A naive Real User Monitoring client fires one network request per metric — one for LCP, one for INP, one for CLS, one for TTFB, plus a handful of custom marks — so a single page view can spray six or more tiny requests at your ingest endpoint. Each one carries TCP, TLS, and header overhead that dwarfs its payload, and each one competes with the very resources whose speed you are trying to measure. This how-to is part of the Custom Performance Beacons & RUM reference, and it shows how to collapse those requests into one durable batch that flushes reliably at end of life using navigator.sendBeacon and the page lifecycle events.

The goal is a buffer that accumulates metrics in memory as they resolve, then flushes exactly once — on visibilitychange to hidden and again on pagehide as a belt-and-braces fallback — so you send a single compact beacon that survives the page being backgrounded, frozen, or unloaded. Getting the transport and the flush timing right is what separates a RUM pipeline that captures the slow tail from one that silently loses it.

Why Batch At All

The overhead of an HTTP request is fixed and mostly independent of body size. A 120-byte beacon still pays for a connection (or at least a stream on a shared HTTP/2 connection), request and response headers, and a round trip. When you send six of them per page view, you multiply that fixed cost sixfold while adding six chances for a request to be cancelled mid-flight by navigation. Batching amortises the fixed cost across every metric and shrinks the number of cancellation windows to one.

The reliability argument matters more than the byte argument. The slowest sessions — a mid-range Android phone on Fast 3G where INP P75 lands above 500 ms — are precisely the ones most likely to navigate away before a per-metric beacon completes. If those beacons drop, your dataset loses its worst samples and every percentile you compute reads optimistically low. Batching plus sendBeacon fixes both halves: one request instead of six, and a transport the browser promises to deliver even after the document is gone.

Per-metric beacons versus one batch Six small requests each pay fixed request overhead, while one batched request pays it once. One request per metric vs one batched request Unbatched: 6 requests LCP beacon INP beacon CLS beacon TTFB beacon mark A mark B 6x fixed overhead Batched: 1 request 1 beacon [LCP, INP, CLS, TTFB, mark A, mark B] 1x fixed overhead
Batching amortises fixed request overhead across every metric and leaves a single cancellation window instead of six.

sendBeacon vs fetch keepalive

Two APIs can send data as a page goes away: navigator.sendBeacon(url, body) and fetch(url, { keepalive: true }). Both hand the request to the browser's out-of-process network stack so it outlives the document, but they differ in ways that decide which one belongs in your flush path. sendBeacon is fire-and-forget: it queues the request, returns a boolean telling you only whether queuing succeeded, and gives you no access to the response. It sends a POST and you cannot set arbitrary headers — the Content-Type is inferred from the body type, so a Blob with an explicit type is how you control it. fetch with keepalive returns a real promise, lets you set headers and method, and lets you read the response, at the cost of being subject to a shared 64 KB cap across all in-flight keepalive requests.

For a terminal RUM flush you almost always want sendBeacon. You are not going to await a response as the page dies, you do not need custom headers, and sendBeacon has the longest and most consistent track record of actually delivering during unload. Reach for fetch keepalive only when you genuinely need something sendBeacon cannot do — a non-POST verb, an Authorization header, or reading a response body for a mid-session flush that is not tied to unload. The batching buffer below uses sendBeacon for the lifecycle flush and keeps fetch keepalive as a documented fallback for when sendBeacon returns false.

sendBeacon versus fetch keepalive Property-by-property comparison of the two unload-safe transports. navigator.sendBeacon fetch keepalive Method: POST only Returns: boolean (queued?) Headers: inferred from Blob type Response: not readable Unload delivery: most reliable Queue cap: per-call, browser set Use for: terminal flush Method: any verb Returns: Promise<Response> Headers: fully controllable Response: readable Unload delivery: good, newer Queue cap: 64 KB shared Use for: mid-session, auth
sendBeacon wins the terminal flush; fetch keepalive covers the cases that need headers, a response, or a non-POST verb.

A Buffer That Flushes On Lifecycle Events

The buffer is a plain array in module scope. As each metric resolves — whether from the web-vitals library or your own PerformanceObserver hooks — you push a compact record onto it rather than sending immediately. Nothing goes to the network until a flush trigger fires. This is safe because the metrics that matter for Core Web Vitals are only final when the page is being hidden or unloaded anyway: INP keeps updating until the last interaction, and CLS accumulates until the page goes away, so buffering until visibilitychange costs you nothing and actually captures more complete values.

The single most important rule of unload-time telemetry is to flush on visibilitychange to hidden, not on the legacy unload or beforeunload events. On mobile, a user who switches apps or locks the screen often never fires unload at all — the page is frozen and later discarded by the browser's back/forward cache or the OS, and unload never runs. visibilitychange to hidden is the last event you are reliably given. Add a pagehide listener as a second trigger for the desktop tab-close path, and guard against double-sending with a flag so a page that is hidden and then closed does not emit two beacons.

Buffer and flush data flow Metric callbacks push into an in-memory buffer that a lifecycle event flushes once via sendBeacon. Accumulate in memory, flush once at end of life onLCP() onINP() onCLS() custom marks buffer[] push, do not send visibilitychange to hidden pagehide (fallback) sendBeacon() once, guarded
Metric callbacks only push; a lifecycle event triggers a single guarded flush through sendBeacon.

Payload Size Caps

sendBeacon enforces a queue limit — historically 64 KB per call in Chromium, and the spec-mandated shared budget for fetch keepalive is also 64 KB across all concurrent keepalive requests. A well-designed RUM batch never comes close: a full Core Web Vitals session using a fixed-key schema is well under 1 KB, so the cap only bites if you accidentally include something large like a full resource-timing dump, a long user-agent string repeated per metric, or an unbounded array of custom marks. The defence is to keep the schema compact — the same discipline covered in designing efficient RUM beacon payloads — and to cap the number of buffered entries.

Because sendBeacon returns false when the body exceeds the queue limit or the queue is full, you must check that boolean and fall back rather than assume success. A silent false is indistinguishable from a dropped session in your ingest logs unless you handle it. The table below gives concrete wire sizes for a compact batch so you can see how much headroom you have before the 64 KB ceiling is even in view.

Batch contents Approx. serialized bytes Fraction of 64 KB cap
4 core vitals (LCP, INP, CLS, TTFB) 300 0.5%
4 core vitals + 4 custom marks 620 0.9%
4 core vitals + 20 resource entries 4800 7.3%
Full resource-timing dump (100 entries) 62000 94.6%
Batch size versus the queue cap Compact batches use a tiny fraction of the 64 KB sendBeacon queue limit. Serialized batch size vs the 64 KB cap 64 KB queue cap 0.5% 4 vitals 0.9% +4 marks 7.3% +20 resources 94.6% full dump
A compact vitals batch sits near the axis; only an unbounded resource dump threatens the queue cap.

The Full Implementation

The client below buffers metrics, flushes once on the first lifecycle signal, and falls back to fetch keepalive when sendBeacon reports false. It uses a Blob with an explicit application/json type so the ingest endpoint receives a normal JSON body, and it shares the session id across every metric so it is serialized once.

const ENDPOINT = "/rum/collect";
const buffer = [];
let flushed = false;
const session = {
  s: crypto.randomUUID(),
  u: location.pathname,
  c: navigator.connection ? navigator.connection.effectiveType : "unknown",
};

// Metric callbacks push a compact record; nothing is sent here.
export function record(name, value, rating) {
  buffer.push({ n: name, v: Math.round(value), r: rating });
}

function serialize() {
  return JSON.stringify({ ...session, m: buffer });
}

function flush() {
  if (flushed || buffer.length === 0) return;
  flushed = true;
  const body = new Blob([serialize()], { type: "application/json" });
  const queued = navigator.sendBeacon(ENDPOINT, body);
  if (!queued) {
    // sendBeacon refused (over cap or queue full): fall back to keepalive.
    fetch(ENDPOINT, { method: "POST", body, keepalive: true }).catch(() => {});
  }
}

// visibilitychange to hidden is the reliable end-of-life signal on mobile.
addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") flush();
});
// pagehide covers the desktop tab-close path and BFCache eviction.
addEventListener("pagehide", flush);

Wire it to your metric source by calling record from each callback. With the web-vitals library that is a one-liner per metric, and the buffer takes care of the rest. Because high-traffic sites sample their RUM traffic before collecting, gate the whole setup behind a sampling decision so you only instantiate the buffer for sessions you intend to keep.

import { onLCP, onINP, onCLS, onTTFB } from "web-vitals";
import { record } from "./rum-buffer.js";

if (Math.random() < 0.10) {           // 10% head-based sample
  onLCP((m) => record("LCP", m.value, m.rating));
  onINP((m) => record("INP", m.value, m.rating));
  onCLS((m) => record("CLS", m.value * 1000, m.rating)); // CLS is unitless; scale
  onTTFB((m) => record("TTFB", m.value, m.rating));
}

Verifying Beacons Actually Fire

A batching client is only as good as its flush, and the flush happens at the moment the page is hardest to inspect. Verify it deliberately rather than assuming it works. Follow these steps to confirm each metric arrives exactly once.

  1. Open DevTools, go to the Network panel, and enable "Preserve log" so requests survive the navigation that triggers the flush.

  2. Load the page, interact with it to resolve INP, then switch to another tab. Filter the Network list for collect and confirm a single request appears with request type ping (that is how sendBeacon shows up) and status 204 or 200.

  3. Inspect the request payload and confirm it is one JSON body containing an m array with every metric — not several separate requests.

  4. Reproduce the double-send guard: hide the tab, then close it, and confirm only one beacon was sent, not two. The flushed flag is what prevents the duplicate.

  5. Simulate the cap by pushing a deliberately huge array into the buffer in the console, call the flush, and confirm the code takes the fetch keepalive fallback path when sendBeacon returns false.

Once beacons land reliably, the downstream job is turning those raw events into stable percentiles, which is covered in building P75/P99 aggregation pipelines. Treat delivery verification as a standing check: a refactor that moves the flush to beforeunload, or that forgets the visibilityState guard, will quietly cut your capture rate on mobile without any error in the console. For context, a healthy batching client on a mid-range Android device on Fast 3G should capture well over 95% of sessions at the P75 mark; if your ingest volume drops below your sampled traffic expectation, the flush path is the first place to look.

Frequently Asked Questions

Should I flush on beforeunload or unload instead of visibilitychange?

No. On mobile, unload and beforeunload frequently never fire because the page is frozen and discarded rather than unloaded, so beacons bound to them are lost for exactly the slowest sessions. Flush on visibilitychange to hidden as the primary trigger and add pagehide as a fallback. Guard with a flag so you never send twice.

Why does sendBeacon return false and how should I handle it?

sendBeacon returns false when the body exceeds the browser queue limit (historically 64 KB) or the user-agent queue is already full. Treat false as a delivery failure: fall back to fetch with keepalive: true, and keep the batch compact so the cap is never in play for a normal vitals payload.

Can I batch metrics that are still updating, like INP and CLS?

Yes, and you should. INP and CLS are only final when the page is hidden or unloaded, so buffering until the lifecycle flush captures a more complete value than sending an early, partial reading. Push the latest value into the buffer on each callback and let the flush serialize whatever is current at end of life.

When is fetch keepalive the better choice over sendBeacon?

Use fetch with keepalive when you need something sendBeacon cannot provide: a non-POST verb, custom headers such as Authorization, or a readable response for a mid-session flush. For the terminal end-of-life flush, sendBeacon remains the most reliable transport across browsers.

How do I confirm the batch actually reached my endpoint?

Enable "Preserve log" in the DevTools Network panel, filter for the ping request type, and confirm a single request with a 204 or 200 status carrying a JSON body with every metric in one array. Then verify the double-send guard by hiding and then closing the tab and confirming only one beacon fired.