Handling Beacon Retries and Offline Queueing
Beacon loss is not random. It concentrates on mobile sessions that background abruptly, on connections that drop mid-request, and on tabs the operating system kills — which is the same population whose metrics are worst. Every lost beacon makes your p75 slightly more optimistic than reality. This guide sits under Self-Hosted Beacon Collection and covers a durable queue, retry rules that cannot produce duplicates, and how to verify the delivery rate you are actually achieving rather than the one you assume.
Prerequisites
- A collector that responds quickly and idempotently, per Building a RUM Ingestion Endpoint on Cloudflare Workers.
- A beacon schema with a stable per-beacon id, so the server can deduplicate.
- Agreement on a retention window: how stale a beacon may be before it is not worth sending.
How to build it
Step 1 — Understand what each transport guarantees
| Transport | Survives page hide | Retryable | Response readable | Use for |
|---|---|---|---|---|
navigator.sendBeacon |
Yes | No | No | The final flush |
fetch with keepalive |
Yes | Yes, before unload | Yes | Mid-session sends |
Plain fetch |
No | Yes | Yes | Retries while the page is alive |
| Background Sync | N/A — runs later | Yes | Yes | Beacons that missed their window |
sendBeacon is fire-and-forget by design: you cannot know whether it arrived. That is acceptable for the last flush and unacceptable as the only path, because the sessions where it fails are exactly the ones you most want to measure.
Step 2 — Persist the queue before you try to send it
// queue.js — durable queue in localStorage, bounded and versioned.
const KEY = 'rum-queue-v1';
const MAX_ITEMS = 200;
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
const read = () => {
try { return JSON.parse(localStorage.getItem(KEY) || '[]'); } catch { return []; }
};
const write = (items) => {
try { localStorage.setItem(KEY, JSON.stringify(items.slice(-MAX_ITEMS))); } catch {
// Storage full or blocked (private mode): fall back to memory only.
}
};
export function enqueue(beacon) {
const items = read();
items.push({ ...beacon, id: beacon.id ?? crypto.randomUUID(), queuedAt: Date.now() });
write(items);
}
export function drainable() {
const now = Date.now();
return read().filter((b) => now - b.queuedAt < MAX_AGE_MS);
}
export function remove(ids) {
const gone = new Set(ids);
write(read().filter((b) => !gone.has(b.id)));
}
Three bounds matter. A maximum item count, so a broken collector cannot fill a user’s storage. A maximum age, so a beacon from last week does not arrive dated today. And a version in the key, so a schema change does not resurrect unparseable rows.
Step 3 — Send with the right transport for the moment
// send.js — three paths, one queue.
import { drainable, remove, enqueue } from './queue.js';
const ENDPOINT = '/rum';
// While the page is alive and healthy: a normal fetch we can retry.
export async function flushOnline() {
const batch = drainable();
if (!batch.length || !navigator.onLine) return;
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ beacons: batch }),
});
if (res.ok) remove(batch.map((b) => b.id));
} catch {
// Leave them queued; the next attempt or the next page load will retry.
}
}
// The page is going away: fire-and-forget, then optimistically clear.
export function flushOnHide() {
const batch = drainable();
if (!batch.length) return;
const body = JSON.stringify({ beacons: batch });
const ok = navigator.sendBeacon(ENDPOINT, new Blob([body], { type: 'application/json' }));
// sendBeacon returns false when the payload was rejected outright (too
// large, or queued bytes exhausted) — in that case keep them for later.
if (ok) remove(batch.map((b) => b.id));
}
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushOnHide();
});
addEventListener('online', flushOnline);
Step 4 — Make retries safe with idempotency
A retried beacon must not become two rows. The client supplies a stable id, and the collector deduplicates on it.
// Collector side: reject duplicates cheaply before writing.
export async function ingest(beacons, env) {
const fresh = [];
for (const b of beacons) {
if (!b.id || typeof b.id !== 'string' || b.id.length > 64) continue;
// A short-TTL KV or Redis set is enough; exact-once is not required,
// near-once is. The window only needs to cover realistic retry delays.
const seen = await env.DEDUP.get(b.id);
if (seen) continue;
await env.DEDUP.put(b.id, '1', { expirationTtl: 86400 });
fresh.push(b);
}
return fresh;
}
Without this, an aggressive retry policy inflates counts for exactly the sessions that had a bad connection — which biases every percentile toward the slow tail rather than away from it. Either direction of bias is wrong; both are avoidable.
Step 5 — Retry on the next page load, not in a tight loop
The most effective retry is not a loop at all: it is draining the persisted queue when the next page loads. Sessions that failed to deliver are usually followed by another page view from the same user, on a better connection.
// On startup: try once, quietly, after the page has settled.
addEventListener('load', () => {
requestIdleCallback?.(() => flushOnline(), { timeout: 3000 }) ?? setTimeout(flushOnline, 1500);
});
Immediate exponential-backoff loops inside a failing session mostly waste battery on a connection that is not coming back. The queue is the retry mechanism; the loop is a distraction.
Step 6 — Use Background Sync where it is available
Where a service worker is already in play, Background Sync delivers the queue after the page is gone and the connection returns. It is Chromium-only, so it is an upgrade rather than a strategy.
// In the page: ask the service worker to deliver what is left.
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
try { await reg.sync.register('rum-flush'); } catch { /* not available */ }
}
// In the service worker: read the same queue and post it.
self.addEventListener('sync', (event) => {
if (event.tag !== 'rum-flush') return;
event.waitUntil(deliverQueuedBeacons());
});
What the recovered beacons change
The reason to do this work is not tidiness — it is that the recovered population is not a random sample of the lost one.
Beacons that fail on the first attempt come disproportionately from slow connections, backgrounded mobile tabs and sessions the operating system terminated. Their metrics are worse than average, so a pipeline that loses them reports a p75 that is optimistic by a margin nobody can see. On the traffic in the chart above, recovering ten points of delivery moved the reported mobile p75 LCP up by roughly 240 ms — the number got worse, and it got more true.
That is worth saying out loud before the work ships, because a performance change that makes the dashboard look worse is otherwise a difficult conversation. Frame it as a measurement correction, record the date, and treat the series as broken at that point rather than as a regression.
Verifying it works
- Delivery rate per exit path, measured by comparing a client-side send counter against collector receipts. Watch the mobile-background and process-killed rows specifically.
- Duplicate rate near zero. Query for repeated beacon ids at the collector; anything above a fraction of a percent means deduplication is not working.
- Queue depth stays bounded. Report the queue length on the next page load; a growing distribution means the collector is failing and nobody noticed.
- Late beacons are labelled. A beacon delivered six hours after it was captured must carry both timestamps or it will be attributed to the wrong window.
- Storage failures degrade quietly. Private-mode sessions with blocked storage should fall back to memory, not throw.
Edge cases and gotchas
sendBeaconreturns false when the payload exceeds the browser’s queued-bytes budget. Check the return value and keep the batch if it fails.- Late delivery skews time-series charts. A queue drained the next morning inserts yesterday’s beacons into today’s window unless you aggregate on the capture timestamp.
- Clock skew is real. Client timestamps can be minutes or years wrong. Store both the client capture time and the server receipt time, and reconcile.
- localStorage is synchronous and blocking. Keep writes small and infrequent; a 200-item queue serialised on every beacon is a main-thread cost of its own.
- Private browsing blocks storage. Detect the throw and continue in memory rather than failing to report at all.
- Background Sync needs a service worker you already have. Adding one purely for beacon delivery is rarely worth its own risks.
FAQ
Does retrying bias my data?
Recovering lost beacons removes a bias that was already there — the lost population is slower than average. Duplicates would introduce a new bias, which is why idempotency is not optional.
How long should a beacon stay in the queue?
Twenty-four hours is a reasonable default. Beyond that the value is questionable and the risk of confusing a time-series chart grows.
Should I retry inside a failing session?
Not aggressively. One retry after a short delay is fine; beyond that, persist and let the next page load deliver it. Loops on a dead connection cost battery and change nothing.
How do I measure delivery rate at all?
Count sends on the client and receipts on the server, keyed by beacon id, and compare over a window. The absolute number matters less than its movement after a change.
Related
- Self-Hosted Beacon Collection — transport choice, payload shape and the wider collection design.
- Rate Limiting and Validating RUM Beacons — the collector-side controls a retrying client makes necessary.
- RUM Data Sampling Strategies — why delivery loss and sampling must not be confused.