navigator.sendBeacon vs fetch keepalive: Reliable Analytics Delivery Without Blocking Unload
How to send analytics payloads that survive page unload without holding up navigation — and why the choice between sendBeacon and fetch keepalive matters for cookieless collection.
Collecting a page view or an event is easy while the page is alive. The hard part is delivering that last payload as the user navigates away, closes the tab, or backgrounds the app on mobile. Get it wrong and you either lose data or, worse, delay the very navigation the user asked for. This post compares the two credible options — navigator.sendBeacon() and fetch() with keepalive: true — and explains which one Monoid's tracker actually uses, and why, without cookies, storage, or fingerprinting.
Why unload is a special case
When a document starts to unload, the browser is winding down. Synchronous XMLHttpRequest was historically abused here to block navigation until a request completed, which harms responsiveness. Modern browsers actively discourage this, and requests started during unload are frequently cancelled. The Beacon specification exists precisely to solve this: it lets a page schedule a request that the user agent guarantees to attempt, asynchronously, without blocking the next page from loading.
The practical implication for analytics: never rely on a normal fetch or XHR fired from a beforeunload/unload handler. It may simply never leave the machine.
navigator.sendBeacon
sendBeacon is purpose-built. It queues a small POST and returns a boolean immediately, telling you only whether the request was successfully queued — not whether it succeeded. The browser then transmits it in the background, even after the page is gone.
const ok = navigator.sendBeacon('/collect', payload);
Strengths:
- The transfer is not tied to the document's lifetime.
- It is low priority and designed not to compete with the next navigation.
- No response handling, so there is nothing to await.
Constraints, per the spec and browser implementations:
- POST only. You cannot set arbitrary methods.
- Limited control over headers. The
Content-Typeis inferred from the payload type (e.g. aBloblets you influence it, but you cannot freely set custom request headers). - Payloads count against a user-agent data limit; oversized beacons return
false.
For cookieless analytics, this is usually enough — plenty of trackers ship exactly this and never look back. Monoid doesn't, for one specific reason: sendBeacon cannot express mode: 'cors', and Monoid's install snippet lets a site point data-api-url at an origin different from the host page. That single constraint decides the comparison below.
fetch with keepalive
The Fetch standard defines a keepalive flag that keeps a request alive beyond the page that initiated it, giving you sendBeacon-like durability with a richer API.
fetch('/collect', {
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: payload,
});
Strengths:
- Full control over method, headers, and body.
- A real
Responseyou can inspect (though during unload you often should not await it).
Constraints:
- The Fetch spec limits the total body size of all in-flight keepalive requests to 64 KiB across the document. Exceed it and the fetch rejects. This is a per-document budget, so several concurrent keepalive requests share it.
- Historically,
keepalivesupport laggedsendBeacon, and there have been implementation quirks. Test on the browsers your audience actually uses.
This is what Monoid's tracker uses for every request — /collect, /duration, and /event alike:
fetch(endpoint, {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
keepalive: true,
mode: 'cors',
});
The mode: 'cors' is the deciding factor from the section above, not payload size or header count — worth remembering, since most write-ups frame the choice purely around size.
Which to choose
A pragmatic rule: use sendBeacon for fire-and-forget telemetry where you need nothing back, and reach for fetch keepalive only when you genuinely need custom headers, a larger structured request, or cross-origin mode control. Keep payloads small either way — well under the 64 KiB keepalive budget — because analytics events should be minimal by design. Data minimisation is not just a compliance posture; it is what makes reliable unload delivery possible. Monoid is the exception the rule already accounts for: the payload is small and the response is never read, exactly the profile sendBeacon was built for — but mode: 'cors' support wins out.
The right event to listen for
Don't hook unload. It is unreliable and breaks the back/forward cache. The Page Lifecycle guidance recommends listening for visibilitychange and treating the transition to hidden as your last reliable moment to flush. This works consistently across desktop close, tab switch, and mobile app backgrounding — cases where unload never fires.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
Monoid's tracker follows this: a visibilitychange listener sits alongside a beforeunload fallback, so a duration sample is reported whether a tab is backgrounded on mobile or closed outright on desktop.
How this fits a cookieless model
Because Monoid never reads cookies or storage and never fingerprints, each request carries only what it already reveals — a coarse event plus headers the browser sends anyway. There is no identifier to persist, so we never need a two-way exchange to sync state. That is what makes fetch keepalive the easy choice once mode: 'cors' takes sendBeacon off the table: a one-shot POST whose response we never inspect is not a limitation for us; it is what aligns delivery reliability with privacy by design.
Comments
Loading comments…