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 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.
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.
| Field (compact key) | Meaning | Example | Approx. bytes |
|---|---|---|---|
s |
session id (uuid) | 9f1c... |
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 therefore roughly 300 bytes after the session id is shared — far inside any practical limit and safe even on constrained networks.
Diagnostic Steps
Measure the real wire size and confirm delivery before trusting the data; an oversized or rejected beacon fails quietly.
-
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 -
Confirm the beacon actually queued —
sendBeaconreturnsfalsewhen the body exceeds the browser's queue limit:const ok = navigator.sendBeacon("/rum/ingest", body); console.log("queued:", ok); // queued: true -
In DevTools, filter the Network panel by the
pingtype and confirm the request shows status204with no response delay on page unload.
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.
// 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();
});
Compression is rarely worth it at this size — gzip's own header overhead can make a sub-300-byte JSON payload larger. Reserve transport compression (Content-Encoding: gzip) for batched payloads above roughly 1 KB; below that, a tight schema beats compression.
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) 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.
# .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.
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. The fallback pattern is shown in Custom Performance Beacons & RUM.
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.