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());
});
Delivery rate by strategyTen points of delivery on mobile, concentrated entirely on the sessions with the worst connections — which is where the missing metrics were.Delivery rate by strategyShare of sessions whose final beacon reached the collector, mobile trafficsendBeacon only88.4%+ persisted queue94.1%+ retry on next page load97.6%+ background sync (Chromium)98.9%0%25%50%75%100%
Ten points of delivery on mobile, concentrated entirely on the sessions with the worst connections — which is where the missing metrics were.

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.

Where beacons were being lostLoss is concentrated where the session ended abruptly. Those sessions are also the ones most likely to have been slow.Where beacons were being lostShare of undelivered final flushes by exit path, before the queueNavigated away99%Tab closed97%Backgrounded on mobile86%14%Process killed by the OS54%46%DeliveredLost
Loss is concentrated where the session ended abruptly. Those sessions are also the ones most likely to have been slow.

Verifying it works

  1. 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.
  2. Duplicate rate near zero. Query for repeated beacon ids at the collector; anything above a fraction of a percent means deduplication is not working.
  3. Queue depth stays bounded. Report the queue length on the next page load; a growing distribution means the collector is failing and nobody noticed.
  4. 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.
  5. Storage failures degrade quietly. Private-mode sessions with blocked storage should fall back to memory, not throw.
What the recovered beacons look likeRecovered beacons are dramatically slower than the ones that arrived first time. Losing them is not a rounding error — it is a systematic bias toward the sessions that went well.What the recovered beacons look likep75 LCP of beacons delivered immediately against those recovered laterDelivered on first attempt2,560 msRecovered on the next page load4,180 msRecovered by background sync5,240 msCombined population2,800 ms0 ms2,500 ms5,000 ms7,500 ms10,000 ms
Recovered beacons are dramatically slower than the ones that arrived first time. Losing them is not a rounding error — it is a systematic bias toward the sessions that went well.

Edge cases and gotchas

  • sendBeacon returns 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.