Reducing Beacon Payload Size for Mobile
On a congested cellular uplink, the bytes you ship matter more than the cleverness of your aggregation. A verbose JSON RUM beacon — full metric names, ISO timestamps, repeated session metadata — can balloon to several kilobytes, and on a high-latency 3G/slow-4G radio every extra packet inflates upload time and raises the odds the beacon is dropped during a route change or backgrounding. This page is the narrow, runnable answer to one question: how do you make a single Real-User Monitoring beacon as small as physically reasonable for mobile, within the self-hosted beacon collection pipeline this section documents, without distorting your field data. It covers short keys and numeric enums, batching several metrics into one beacon, delta encoding, CompressionStream (gzip) before send, MessagePack versus JSON, staying under sendBeacon size limits, and — importantly — when compression is not worth it on a tiny payload.
Prerequisites
Before you start shrinking beacons, confirm the following are in place:
- A working RUM collector that captures vitals in the browser, ideally via the web-vitals API implementation pattern, so each metric arrives as a structured object you control rather than free-form analytics events.
- An ingestion endpoint you own and can change — for example the RUM ingestion endpoint on Cloudflare Workers — because every client-side encoding change needs a matching server-side decoder.
- A defined wire schema: an agreed map of metric → short key → numeric enum, versioned, so the decoder can be deployed before the encoder ships.
- A baseline measurement of current beacon size at p75 across mobile sessions. Without a per-session size distribution you cannot tell whether a change helped, and you should be aggregating it at p75 just like your RUM data sampling percentiles, not at the mean where one huge outlier hides everything.
How to shrink a mobile RUM beacon
The steps below build a single batched, compact beacon. Apply them in order — short keys first (free, lossless), then batching and delta encoding (structural), then compression last (only if the payload is large enough to justify it).
1. Replace long keys with short keys and numeric enums
JSON spends most of its bytes on repeated property names and string-valued categoricals. Map every field to a one- or two-character key, and map every low-cardinality string (metric name, connection type, device tier, navigation type) to a small integer. This is lossless and the single highest-leverage change.
// Shared wire schema — keep this versioned and identical on both ends.
const METRIC = { LCP: 0, INP: 1, CLS: 2, FCP: 3, TTFB: 4 };
const RATING = { good: 0, 'needs-improvement': 1, poor: 2 };
const NAVTYPE = { navigate: 0, reload: 1, 'back-forward': 2, prerender: 3 };
// Verbose source object from your collector.
function toCompact(sample) {
return {
v: 2, // schema version
m: METRIC[sample.name], // numeric enum instead of "LCP"
val: Math.round(sample.value), // integers; CLS is scaled below
r: RATING[sample.rating],
nt: NAVTYPE[sample.navigationType] ?? 0,
};
}
Why: {"metricName":"LCP","rating":"good"} is ~35 bytes of mostly constant text; {"m":0,"r":0} is ~13. Rounding LCP/INP to whole milliseconds and scaling CLS to an integer (e.g. Math.round(cls * 1000)) removes float noise that gzip cannot compress away. None of this loses meaning — 0.08 CLS as 80 is exact.
2. Batch all of a page’s metrics into one beacon
Sending a separate beacon per metric multiplies the fixed cost — session id, page id, connection metadata, HTTP/request overhead — by five. Collect metrics for the page’s lifetime and flush once.
const buffer = [];
const session = {
s: crypto.randomUUID().slice(0, 8), // short session id
u: location.pathname, // path only, no query string
c: connEnum(navigator.connection?.effectiveType), // 0..3
};
function record(sample) {
buffer.push(toCompact(sample));
}
function connEnum(t) {
return { 'slow-2g': 0, '2g': 0, '3g': 1, '4g': 2 }[t] ?? 2;
}
Why: session and connection metadata are written once per beacon instead of once per metric. For a page reporting LCP, INP, CLS, FCP and TTFB, batching turns five payloads into one and removes four copies of the constant envelope.
3. Delta-encode metrics that update over the page lifetime
INP and CLS are not final until the page is hidden — they accumulate. If you ever send interim updates, send only the change since the last report rather than the whole state, and let the server reconstruct the running value.
const last = new Map(); // metric enum -> last sent integer value
function delta(sample) {
const c = toCompact(sample);
const prev = last.get(c.m) ?? 0;
c.d = c.val - prev; // transmit delta
delete c.val;
last.set(c.m, c.val ?? prev + c.d);
return c;
}
Why: a CLS that drifts 0.04 → 0.06 → 0.07 sends deltas 40, 20, 10 (small, often single-digit) instead of three full values. For most pages you flush once at pagehide and deltas are unnecessary; reach for this only when you genuinely emit progressive updates, because the per-field d key has its own cost.
4. Flush exactly once, at page lifecycle end, with sendBeacon
Build the final compact object and hand it to the sendBeacon API documented in the web-vitals implementation guide. sendBeacon survives the page being discarded, which is the whole point on mobile where users navigate away mid-upload.
function flush() {
if (!buffer.length) return;
const payload = { ...session, e: buffer.splice(0) }; // envelope + events
const body = JSON.stringify(payload);
navigator.sendBeacon('/rum', new Blob([body], { type: 'application/json' }));
}
// Fire on the last reliable lifecycle signal. visibilitychange→hidden is the
// most reliable across mobile Safari and Chrome; pagehide is a backstop.
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush, { capture: true });
Why: a single flush at hidden captures finalized INP and CLS and avoids speculative mid-session sends entirely. Do not flush on beforeunload — it is unreliable on mobile and blocks the browser’s back/forward cache.
5. Gzip the body with CompressionStream — but only above the break-even size
CompressionStream (gzip) is built into modern browsers and needs no library. It shines on larger, repetitive bodies and is counterproductive on tiny ones because gzip has ~18 bytes of header/footer overhead and a payload that does not compress can come out larger.
async function gzip(str) {
const stream = new Blob([str]).stream()
.pipeThrough(new CompressionStream('gzip'));
return new Response(stream).blob(); // returns a gzipped Blob
}
async function flushCompressed() {
if (!buffer.length) return;
const body = JSON.stringify({ ...session, e: buffer.splice(0) });
// Break-even: below ~1 KB, gzip overhead usually outweighs the saving.
if (body.length < 1024) {
navigator.sendBeacon('/rum', new Blob([body], { type: 'application/json' }));
return;
}
const gz = await gzip(body);
// sendBeacon cannot set Content-Encoding, so signal it in the URL or MIME.
navigator.sendBeacon('/rum?enc=gzip', gz);
}
Why: gzip on a 4 KB verbose body can save 60–70%, but on a 300-byte already-compact body it adds bytes. sendBeacon cannot set request headers, so you cannot send Content-Encoding: gzip; signal compression in the query string or MIME type and gunzip server-side. Because gzip() is async and pagehide does not await promises, only use compression on the visibilitychange → hidden path where there is time to finish, and keep the uncompressed sendBeacon as the unload backstop.
6. Use MessagePack instead of JSON only if you already need a binary path
MessagePack encodes the same structure in fewer bytes than JSON (no quotes, compact integers) and pairs well with short keys. It needs a library (@msgpack/msgpack) on both ends, so it earns its keep when payloads are large or you are already running a binary decoder at ingestion.
import { encode } from '@msgpack/msgpack';
function flushMsgpack() {
if (!buffer.length) return;
const bin = encode({ ...session, e: buffer.splice(0) });
navigator.sendBeacon('/rum', new Blob([bin], { type: 'application/msgpack' }));
}
Why: MessagePack typically trims 20–40% off compact JSON before any gzip. But for already short-keyed beacons under ~1 KB the absolute saving is small, and the bundle cost and server decoder are real. Prefer short keys + selective gzip first; add MessagePack only when measurement shows JSON envelope overhead still dominates.
Verifying it works
Confirm the reduction end to end rather than trusting the encoder in isolation:
- Measure the wire bytes, not the object. In the flush function, log
new Blob([body]).size(orgz.size) and surface it as abeacon_bytesfield your collector tracks. Watch the p75 of that field for mobile sessions in your RUM dashboard fall after deploy. - Inspect in DevTools. Open the Network panel, filter to your
/rumendpoint, and check the request size column. Throttle to “Slow 4G” under the network conditions dropdown and confirm beacons still complete onvisibilitychange. - Verify decode parity. Add a server-side assertion that the decoded record reconstructs the same metric values as a known fixture. A size win that silently corrupts CLS scaling is worse than no win.
- Confirm delivery rate, not just size. Track the ratio of beacons received to sessions started, segmented by connection enum. The goal of smaller payloads is a higher delivery rate on slow radios; if size dropped but delivery did not improve, the bottleneck was latency, not bytes.
| Signal | Where to read it | Healthy result |
|---|---|---|
| Beacon wire size (p75, mobile) | beacon_bytes in RUM store |
Materially below baseline |
| Beacon delivery rate (3G/slow-4G) | received ÷ sessions, by c enum |
Increases after rollout |
| Decode error rate | Ingestion endpoint logs | Stays at zero |
| Compression skip rate | < 1024 branch counter |
Most tiny beacons skip gzip |
Edge cases & gotchas
sendBeaconsize limit. The spec caps queued beacon bytes (commonly ~64 KB across all in-flight beacons per agent); over the limit,sendBeaconreturnsfalseand silently drops. Always check the boolean return and fall back tofetch(..., { keepalive: true }). Your batched mobile beacon should be well under this, but a runaway error-stack field can blow it.- Compression cannot use request headers. Because
sendBeaconsets no headers, the server must detect gzip from the URL flag or MIME type and gunzip accordingly. Forget this and the collector parses gzip bytes as JSON and rejects every compressed beacon. - Async gzip vs. synchronous unload.
CompressionStreamis promise-based;pagehide/beforeunloadwill not wait for it. Compress only on thevisibilitychange → hiddenpath, and keep a synchronous uncompressedsendBeaconas the final unload fallback so you never lose the beacon to a half-finished stream. CompressionStreamavailability. Older Safari lacks it. Feature-detecttypeof CompressionStream !== 'undefined'and send uncompressed when absent — never throw on the hot path.- Over-aggressive enums break forward compatibility. If you renumber an enum, old in-cache pages still send the old codes. Always add new enum values at the end, never reassign, and key the decoder on the schema version field (
v). - Schema drift between encoder and decoder. Deploy the decoder that understands the new compact format before shipping the encoder. Reversing the order silently drops a window of mobile beacons.
FAQ
Should I always gzip RUM beacons?
No. gzip has fixed header overhead (~18 bytes) and helps only on larger, repetitive bodies. Below roughly 1 KB, a short-keyed JSON or MessagePack beacon is usually already at or below what gzip would produce, and compressing can add bytes. Gate compression on a size threshold and skip it for tiny payloads.
MessagePack or JSON for mobile beacons?
Start with short keys plus numeric enums in JSON — it is lossless, needs no library, and removes most of the waste. Add MessagePack only when measurement shows the JSON envelope still dominates and you already run a binary decoder at ingestion, since it adds a bundle cost and a server-side dependency.
Does rounding metric values hurt accuracy?
No, when done correctly. Rounding LCP and INP to whole milliseconds and scaling CLS to an integer (value × 1000) is below the noise floor of field measurement and does not move your p75. It also strips float artifacts that compression cannot remove, so the wire payload shrinks for free.
Related
- Self-Hosted Beacon Collection — the parent guide to capturing, transmitting, and ingesting RUM beacons.
- RUM Ingestion Endpoint on Cloudflare Workers — the server side that must decode short keys, enums, and gzip.
- Web Vitals API Implementation — how to capture metrics and send them with the sendBeacon API.