Designing Efficient RUM Beacon Payloads

A beacon that drops on a flaky mobile connection silently removes the slowest sessions from your data, biasing every percentile optimistically — exactly the opposite of what a field-monitoring system is for. Payload design is therefore a reliability problem before it is a bandwidth one. This guide is part of the Custom Performance Beacons & RUM reference and covers compact schemas, the right transport, batching, compression, and a CI-enforced size budget. The metrics that feed these payloads are captured with the patterns in Injecting Custom Metrics via PerformanceObserver.

The two levers are what you send (a fixed-key schema, not verbose JSON) and how you send it (sendBeacon for terminal metrics, fetch with keepalive when you need a response). Get both right and a full vitals payload fits in well under 1 KB with headroom to spare — small enough that even a mid-range mobile device on Fast 3G, whose slow uplink is where beacons are most likely to be abandoned, can flush it inside the browser's unload window at the P90 of session-end conditions.

Payload Field Budget

A compact, fixed-key schema keeps every beacon predictable and small. Below is the field-by-field byte budget for a single-metric payload; verbose keys like "sessionId" or "deviceMemory" would more than double it for no analytical gain. The key names travel on the wire in every single beacon, so a three-character saving per field is multiplied by your entire traffic volume.

Field (compact key) Meaning Example Approx. bytes
s session id (uuid) 9f1c8a2e 38
n metric name LCP 8
v value (integer ms) 2410 8
r rating good 10
c effective connection 4g 7
u route path /checkout 14
JSON overhead braces, quotes, commas ~12
Total per metric ~105 bytes

A four-metric session (LCP, INP, CLS, TTFB) batched into one beacon is roughly 300 bytes after the session id is shared once — far inside any practical limit and safe even on constrained networks. The verbose equivalent, with human-readable keys and pretty-printing, runs closer to 260 bytes per metric, or over 1 KB for the same four metrics. The chart below shows why the fixed-key schema wins outright and why reaching for compression at this size is counterproductive.

Bytes per metric by encoding Verbose keys cost about 260 bytes per metric, a compact schema about 105 bytes, and gzipping the tiny payload grows it back to about 130 bytes. 0 130 260 Bytes per metric 260 B 105 B 130 B Verbose keys Compact schema gzip(compact) Lower is better — compression backfires below 1 KB
A fixed-key schema more than halves per-metric bytes; gzipping a sub-1 KB body adds framing overhead and grows it again.

Choosing the Transport

Two APIs survive the page-unload window that terminal metrics like LCP and CLS depend on. sendBeacon is purpose-built for fire-and-forget delivery: it queues the request with the browser and returns immediately, needs no open connection, and never blocks navigation. Its ceiling is the browser's queue limit — around 64 KB of pending beacon data, and it returns false if you exceed it. fetch with keepalive: true also outlives unload, allows a larger body, and hands you a readable response, at the cost of a slightly heavier call. The rule is simple: default to sendBeacon, and fall back to keepalive fetch only when the body is large or you genuinely need the server's reply (for example, a tail-based sampling verdict). The decision tree below encodes that policy; the Batching Beacons With sendBeacon guide walks the queue mechanics in depth.

Transport selection If the body exceeds 60 KB or a response is needed, use keepalive fetch; otherwise use sendBeacon. Metric batch ready Body over 60 KB? Need a response? fetch keepalive sendBeacon Yes No Yes No
Default to sendBeacon; escalate to keepalive fetch only for oversized bodies or when a server response is required.

Diagnostic Steps

Measure the real wire size and confirm delivery before trusting the data; an oversized or rejected beacon fails quietly, and a silent failure on slow connections is precisely the bias you are trying to avoid.

  1. Compute the serialized size of a representative payload in the console:

    const body = JSON.stringify({ s: crypto.randomUUID(), n: "LCP", v: 2410, r: "good", c: "4g", u: "/checkout" });
    console.log(new Blob([body]).size, "bytes");
    // 118 bytes
  2. Confirm the beacon actually queued — sendBeacon returns false when the body exceeds the browser's queue limit or the tab is already discarded:

    const ok = navigator.sendBeacon("/rum/ingest", body);
    console.log("queued:", ok); // queued: true
  3. In DevTools, filter the Network panel by the ping request type and confirm the request shows status 204 with no response body on page unload. If you see the request fire but no 204, the endpoint is rejecting the content type — sendBeacon sends text/plain by default unless you wrap the body in a Blob with an explicit type.

Implementation

This module batches a session's metrics into one fixed-key payload and sends it once on page hide with sendBeacon. When a body would exceed the safe sendBeacon ceiling it falls back to fetch with keepalive, which has a higher limit and still survives unload. Collecting all metrics into a single batch and flushing once — rather than firing a beacon per metric — is what keeps a four-metric session near 300 bytes and one round trip instead of four.

// beacon-transport.js
const ENDPOINT = "/rum/ingest";
const SAFE_BEACON_BYTES = 60000; // stay well under the ~64KB sendBeacon cap
const session = crypto.randomUUID();
const batch = [];

export function queueMetric(name, value, rating) {
  batch.push({ n: name, v: Math.round(value), r: rating });
}

function flush() {
  if (!batch.length) return;
  const body = JSON.stringify({
    s: session,
    c: navigator.connection?.effectiveType || "",
    u: location.pathname,
    m: batch.splice(0), // array of {n,v,r}
  });
  const size = new Blob([body]).size;

  if (size <= SAFE_BEACON_BYTES && navigator.sendBeacon(ENDPOINT, body)) return;
  // Fallback: keepalive fetch survives unload and allows larger bodies.
  fetch(ENDPOINT, { method: "POST", body, keepalive: true }).catch(() => {});
}

// visibilitychange is the reliable flush trigger on mobile; unload is not.
addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") flush();
});

The visibilitychange trigger matters more than it looks. Mobile browsers frequently discard a backgrounded tab without ever firing unload or beforeunload, so listeners bound to those events lose the final beacon on exactly the sessions you most need — a user closing the tab after a slow checkout. Flushing when visibilityState becomes hidden captures that moment reliably. The full collection-to-delivery path is shown below.

Beacon collection and flush flow Metrics from PerformanceObserver queue into a batch array, then drain through flush on tab hide to sendBeacon or fetch and the ingest endpoint. PerformanceObserver queueMetric() batch[ ] buffer visibilitychange state = hidden flush() sendBeacon / keepalive fetch 204 stored drain batch
Metrics accumulate in a single buffer and drain through one flush on tab hide, yielding one round trip per session.

When Compression and Extra Batching Help

Compression is rarely worth it at single-payload size — gzip's own header and framing overhead can make a sub-300-byte JSON payload larger, and the CPU cost lands on the user's main thread at the worst moment. Reserve transport compression (Content-Encoding: gzip) for aggregated payloads above roughly 1 KB: multi-session buffers, long attribution strings, or element-timing detail. There is a second batching axis worth knowing. The per-session flush above sends once per page. If you buffer across soft navigations in a single-page app, or coalesce several tabs' worth of a shared worker, payloads grow past the compression break-even and gzip starts to pay. Where and how aggressively you sample upstream of all this is the domain of Head-Based vs Tail-Based RUM Sampling, which decides whether a session ever produces a beacon in the first place.

Keep integer values, not floats: Math.round(value) on an INP of 184.7 ms saves the two decimal digits and the point on every sample, and no dashboard reads field INP below whole-millisecond resolution. Drop fields whose value is empty rather than sending "c":""; an absent key costs nothing on the wire.

CI Gating Assertion

A synthetic check keeps the payload budget honest: a regression that fattens the beacon (a verbose new field, an accidental stack trace, a stringified error object) should fail before it ships. This job loads the instrumented page under Playwright, captures the outbound beacon body, and asserts its size against a hard budget of 1 KB.

# .github/workflows/beacon-size-gate.yml
name: Beacon Payload Size Gate
on: [pull_request]
jobs:
  beacon-size:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20", cache: "npm" }
      - run: npm ci && npx playwright install --with-deps chromium
      - name: Capture and assert beacon size
        run: |
          node -e '
            const { chromium } = require("playwright");
            const MAX_BYTES = 1024;
            (async () => {
              const b = await chromium.launch();
              const p = await b.newPage();
              let size = null;
              p.on("request", r => {
                if (r.url().includes("/rum/ingest")) size = Buffer.byteLength(r.postData() || "");
              });
              await p.goto(process.env.PREVIEW_URL || "http://localhost:8080/");
              await p.evaluate(() => document.dispatchEvent(new Event("visibilitychange")));
              await p.waitForTimeout(500);
              await b.close();
              console.log(`[BeaconGate] payload=${size}B budget=${MAX_BYTES}B`);
              if (size === null || size > MAX_BYTES) process.exit(1);
            })();
          '
        env:
          PREVIEW_URL: ${{ secrets.PREVIEW_URL }}

Verification

The passing signal is a single captured beacon under budget:

[BeaconGate] payload=312B budget=1024B

Manually, open the deployed page, trigger a tab switch, and confirm in the Network panel exactly one /rum/ingest request of type ping (or a keepalive fetch for oversized batches), status 204, with the compact body visible in the request payload. If you see multiple beacons per session, batching is not firing on visibilitychange; if the size creeps past budget, a new field was added without trimming — return to the field budget table. Once payloads land reliably, they feed the percentile math in Building P75/P99 Aggregation Pipelines, where a dropped tail would quietly flatter your P90 on mid-range mobile.

Frequently Asked Questions

When should I use fetch keepalive instead of sendBeacon?

Use sendBeacon for fire-and-forget terminal metrics — it is purpose-built to queue a request during unload and needs no response. Switch to fetch with keepalive: true when the body might exceed the roughly 64 KB sendBeacon limit, or when you need the response (for example, a server-assigned sampling decision). Both survive page unload; keepalive simply allows larger bodies and a readable response.

Is compressing the beacon worth it?

Not for a single sub-1 KB payload — gzip's framing overhead can make a tiny JSON body larger, and the CPU cost runs on the user's main thread. Compression pays off only when you batch many sessions or large attribution strings into payloads above roughly 1 KB. Below that, a fixed-key schema that avoids verbose field names beats compression every time.

Why flush on visibilitychange rather than unload?

Mobile browsers routinely discard a backgrounded tab without ever firing unload or beforeunload, so a beacon bound to those events is lost on exactly the sessions you care about most — a user leaving after a slow page. Listening for visibilityState === "hidden" captures that moment reliably across desktop and mobile, which keeps your slowest P75 and P90 samples in the dataset instead of silently dropping them.

How large can a batched beacon safely get?

Keep the sendBeacon body under about 60 KB to stay clear of the browser's roughly 64 KB queue ceiling, which is a shared budget across all pending beacons on the page. A typical four-metric session is near 300 bytes, so you have enormous headroom. If a payload ever approaches the cap because you are buffering across soft navigations, the code falls back to keepalive fetch, which accepts larger bodies.