Losing Analytics on Page Unload? sendBeacon Is the Fix You Already Have
Browsers cancel pending fetch requests when a page unloads, silently dropping your analytics. navigator.sendBeacon() queues the payload with the browser itself, so it ships even after the tab closes.
11 Jan 2026, 17:04 UTC

You've probably seen it in your analytics dashboards: session counts that don't add up, "exit" events that never arrive, funnels that mysteriously end one step early. The usual culprit isn't your tracking code — it's the browser. When a user closes a tab or navigates away, the browser cancels pending network requests, and that final fetch() or XMLHttpRequest carrying your "user left" event dies with the page.
The HTML5 navigator.sendBeacon() API exists specifically for this problem. It hands your payload to the browser's networking layer, which dispatches it in the background even after the page is gone. No hacks, no synchronous XHR blocking the unload, no invisible image tricks.
Why fetch and XHR fail at unload time
During unload or pagehide, the browser is tearing down the document. Asynchronous requests initiated from those handlers are routinely cancelled before they hit the network. Developers historically worked around this with synchronous XHR (which freezes the unload and is now deprecated in that context) or by setting an Image src and hoping. Both are fragile and user-hostile.
sendBeacon sidesteps the whole issue: you call it, it returns true if the browser accepted the payload for delivery, and the browser takes it from there. The request is queued and sent as a POST, potentially batched with other network activity, so it doesn't race the page teardown.
A working example: tracking exits reliably
Here's a minimal pattern for sending a small event payload when the page is hidden or closed:
function sendExitEvent() {
const payload = JSON.stringify({
event: 'page_exit',
path: location.pathname,
ts: Date.now()
});
const blob = new Blob([payload], { type: 'application/json' });
const queued = navigator.sendBeacon('/track', blob);
if (!queued) {
// Payload rejected (e.g., too large) — fall back or drop gracefully
console.warn('Beacon not queued');
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') sendExitEvent();
});Run this in any page's context — no special permissions needed. A few notes on the choices here:
visibilitychangeoverunload: thehiddenstate is the most reliable signal across desktop and mobile.unloaddoesn't fire reliably on mobile browsers, andbeforeunloadis worse.Blobwith a JSON type:sendBeaconaccepts a string,Blob,ArrayBuffer,ArrayBufferView, orFormData. Using a typedBlobsets theContent-Typeheader so your server can parse the body correctly. A plain string sends astext/plain.- Check the return value:
truemeans the browser queued it;falsemeans it refused (typically because you've exceeded the browser's beacon quota). It's a queue acceptance signal, not a delivery guarantee.
The server side and the CORS catch
Your endpoint must accept a POST. If the endpoint is same-origin, you're done. If it's cross-origin — common when analytics live on a separate domain — sendBeacon sends a "simple request" with no preflight, but the response still needs appropriate CORS headers (Access-Control-Allow-Origin) if any page code will read it. In practice you usually don't read the response at all, so the main requirement is that the server doesn't reject the request and handles the content type you send. Note that a JSON-typed Blob is fine, but some server frameworks won't parse text/plain bodies by default — check your body-parser configuration.
Trade-offs and limitations worth knowing
- No response handling. Beacons are fire-and-forget. You can't read a status code or retry on failure from the page — by design, the page may already be gone. If you need acknowledgement, beacon the event and reconcile server-side.
- Size limits. Browsers cap the total queued beacon data (the spec leaves this to implementations; keep payloads to a few kilobytes). Batch aggressively but don't ship megabytes.
- POST only, no custom headers. You can't set arbitrary headers, so authentication must ride in the URL, a cookie, or the payload itself.
- "Queued" isn't "delivered." A crashed browser or killed network stack can still lose the request. Delivery is far more reliable than unload-time fetch, but treat analytics as approximate, not transactional.
How to verify it actually works
Don't trust the code — watch the wire. Two quick checks:
- DevTools: open the Network tab, trigger
navigator.sendBeacon('/track', 'test=1')in the console, and confirm a POST appears. Then enable "Preserve log," navigate away, and confirm the request still completes. - Server side: log incoming requests to
/track, close the tab immediately after triggering the beacon, and verify the payload arrives in the log within a few seconds. If it shows up after the window closed, the background dispatch is working.
Browser support is universal across evergreen Chrome, Firefox, Safari, and Edge — this has been safe to use without a polyfill for years. If you're still losing exit events to cancelled fetches, swapping in sendBeacon is a ten-minute change with an immediate, measurable payoff in data completeness.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.