Why sendBeacon Is the Right Way to Log Data on Page Unload
There's a category of web request that's surprisingly hard to get right: the last request a page makes before the user navigates away or closes the tab — the "they're leaving, log this" request. Analytics events (session end, final engagement metrics), telemetry, and "save my draft on exit" all need to fire reliably during the unload/pagehide moment. But this is exactly when the browser is tearing the page down, and it will happily kill an in-flight fetch or XHR the instant the page goes away — so your carefully-collected end-of-session data simply never arrives. The historical hack was a synchronous XHR (block the unload until the request completes), which worked but at a terrible cost: it froze the browser during navigation, making the whole experience janky, and browsers are actively removing it. The Beacon API (navigator.sendBeacon) exists precisely for this: it queues a small request that the browser guarantees to send even after the page is gone, asynchronously, without blocking the unload — fire-and-forget, reliable, non-janky. Understanding why unload-time requests fail, why sync XHR is the wrong fix, how sendBeacon guarantees delivery, and the critical visibilitychange/pagehide lifecycle (because unload itself is unreliable) is essential for anyone doing analytics or telemetry.
The Problem: Requests Die When the Page Does
The unload moment is hostile to normal requests, and the workarounds each have fatal flaws:
| Approach | Reliable on unload? | Blocks unload? | Status |
|---|---|---|---|
fetch() / async XHR | no (killed on unload) | no | lost data |
| Synchronous XHR | yes | yes (freezes browser) | deprecated/removed |
fetch(..., {keepalive:true}) | yes (small) | no | modern alternative |
navigator.sendBeacon | yes | no | the right tool |
<img> ping hack | partially | no | legacy, limited |
| Constraint | Detail | Consequence |
|---|---|---|
| Page teardown kills requests | unload cancels in-flight fetch/XHR | end-of-session data lost |
unload is unreliable | doesn't fire on mobile/bfcache | use pagehide/visibilitychange |
| Beacon payload limit | small (~64KB) | for analytics, not bulk |
| Fire-and-forget | no response readable | can't get a result back |
The core problem: the browser cancels in-flight fetch/XHR requests when the page unloads, so the most important analytics request (the one logging that the session ended) is exactly the one most likely to be dropped. The naive fix (sync XHR to block until it completes) works but freezes the browser during navigation — a terrible UX that browsers are eliminating. The Beacon API solves it correctly: a request the browser commits to sending even after the page is destroyed, asynchronously, without blocking. The catch is it's fire-and-forget (you can't read a response) and small (suited to analytics, not bulk data).
Architecture: How sendBeacon Guarantees Delivery
Normal request on unload: Beacon on unload:
┌──────────────┐ ┌──────────────┐
│ page │ fetch(url, data) │ page │ sendBeacon(url, data)
│ unloading... │──────X (canceled when │ unloading... │──────► handed to browser
└──────────────┘ page is destroyed) └──────────────┘ (returns true)
page gone → request killed → DATA LOST page gone │
▼
┌──────────────────────────────────┐
│ BROWSER (outlives the page) │
│ sends the queued beacon in the │
│ background, after the page is gone │
│ → request COMPLETES → data arrives │
└──────────────────────────────────┘
The mechanism: navigator.sendBeacon(url, data) hands the request to the browser (not the page), which owns and completes it independently of the page's lifecycle. The call returns immediately (true if queued successfully), the page can unload, and the browser sends the beacon in the background — so it arrives even though the originating page is destroyed. This is the key distinction: a normal fetch is tied to the page (dies with it); a beacon is tied to the browser (survives the page). It's sent as a POST with the data, asynchronously, low-priority, fire-and-forget.
Using sendBeacon
The API is dead simple — a URL and a payload, returning whether it was queued.
// Fire-and-forget analytics that survives page unload.
function sendAnalytics(data) {
const payload = JSON.stringify(data);
// returns true if the browser queued it for delivery, false if it couldn't
const queued = navigator.sendBeacon('/analytics', payload);
if (!queued) {
// rare fallback (e.g., payload too large) — try keepalive fetch
fetch('/analytics', { method: 'POST', body: payload, keepalive: true });
}
}
// sendBeacon sends a POST; you can pass a Blob to control Content-Type:
navigator.sendBeacon('/analytics',
new Blob([JSON.stringify(data)], { type: 'application/json' }));
sendBeacon returns true if the user agent successfully queued the request for transfer, false otherwise (e.g., the payload exceeds the size limit, or too many beacons are already queued). It always sends a POST; to set the Content-Type (e.g., application/json), pass a Blob with the desired type rather than a raw string (a raw string defaults to text/plain). You cannot read a response — it's fire-and-forget by design, which is correct for analytics (you don't need a reply) and is part of why it can complete after the page is gone.
The Lifecycle Trap: unload Is Unreliable
Here's the part that breaks naive implementations: you'd think to send the beacon in the unload event — but unload is unreliable and is being deprecated. It often doesn't fire on mobile (where users switch apps or the OS kills the tab) and it breaks the back-forward cache (bfcache) (a page with an unload handler can't be cached for instant back-navigation, hurting performance). The modern, reliable approach uses visibilitychange (fires when the tab is hidden — the most reliable signal that the user may be leaving) and pagehide (fires on actual navigation away, including into bfcache).
// ❌ DON'T rely on 'unload' — unreliable on mobile, breaks bfcache.
window.addEventListener('unload', () => navigator.sendBeacon('/end', data));
// ✅ DO use visibilitychange (most reliable) + pagehide as the signals.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// tab hidden — the user may be leaving/backgrounding. Send NOW.
navigator.sendBeacon('/analytics', JSON.stringify(getSessionData()));
}
});
window.addEventListener('pagehide', () => {
// navigating away (or entering bfcache) — last chance.
navigator.sendBeacon('/analytics', JSON.stringify(getSessionData()));
});
The reliable pattern: send on visibilitychange → hidden (this fires when the user switches tabs/apps or minimizes — catching the common mobile case where unload never fires) and pagehide (for actual navigation). Crucially, you should send your "session is ending" beacon when the page becomes hidden, not wait for unload, because hidden may be the last moment your code runs (the OS could kill a backgrounded tab without ever firing unload). This lifecycle understanding — visibilitychange/pagehide, never unload — is as important as the Beacon API itself; using sendBeacon in an unload handler still loses data on mobile.
fetch keepalive: The Modern Alternative
fetch gained a keepalive: true option that does essentially what sendBeacon does — lets the request outlive the page — with more flexibility (you can set method, headers, read the response if the page survives). It shares the same small size limit (~64KB total for all in-flight keepalive requests). The choice: sendBeacon is simpler and purpose-built (fire-and-forget POST); fetch keepalive is more flexible (custom headers, GET/PUT, response handling) but more verbose.
// fetch keepalive: like sendBeacon but with full request control.
fetch('/analytics', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json', 'Authorization': token },
keepalive: true, // ← survives page unload (like a beacon)
});
// Use this when you need custom headers/auth; sendBeacon for simple fire-and-forget.
Both are correct unload-safe choices; pick sendBeacon for simplicity and fetch keepalive when you need headers (e.g., auth tokens) or a non-POST method. Both share the small payload budget, so neither is for bulk uploads.
Production Realities and Incidents
Incident 1: The Lost Session-End Events
An analytics system logged "session end" with a normal fetch in an event handler at page unload; a huge fraction of these events never arrived, skewing engagement metrics (sessions appeared to never end). Root cause: the browser canceled the in-flight fetch when the page unloaded. Fix: navigator.sendBeacon, which the browser completes after the page is gone. End-of-session requests must use a beacon (or keepalive fetch); a normal fetch on unload is unreliable.
Incident 2: The unload Handler That Killed bfcache and Mobile Data
A team used sendBeacon but called it in an unload handler. Mobile users' data was still lost (unload doesn't fire when the OS kills a backgrounded tab), and the unload listener disabled bfcache, slowing back-navigation. Root cause: relying on unload. Fix: send on visibilitychange → hidden (the reliable mobile signal) and pagehide, removing the unload handler entirely. The Beacon API only helps if you fire it at the right lifecycle moment — unload is the wrong one.
Incident 3: The Sync-XHR Freeze
An older analytics library used synchronous XHR on unload to "guarantee" delivery; it worked but froze the browser for hundreds of milliseconds on every navigation, making the site feel sluggish, and started throwing as browsers removed sync XHR on unload. Root cause: sync XHR blocks the unload (and is being removed). Fix: sendBeacon (async, non-blocking, reliable). Sync XHR was the old hack; the Beacon API is the purpose-built replacement that doesn't freeze the page.
Tradeoffs and Engineering Decisions
- sendBeacon vs normal fetch on unload.
sendBeaconis reliable on unload (browser completes it after the page is gone) and non-blocking — the right tool for end-of-session data; a normalfetch/XHR is canceled when the page unloads, losing the data. For any "log this as they leave" request, use a beacon (or keepalive fetch). - sendBeacon vs sync XHR. Both deliver on unload, but sync XHR blocks the unload (freezes the browser, terrible UX, being removed) while
sendBeaconis async/non-blocking. There's no reason to use sync XHR anymore — the Beacon API replaces it without the freeze. - sendBeacon vs fetch keepalive.
sendBeaconis simpler (fire-and-forget POST, no response) and purpose-built;fetch keepaliveis more flexible (custom headers/auth, any method, optional response) but verbose. UsesendBeaconfor plain analytics;fetch keepalivewhen you need headers (auth) or a non-POST request. Both share the ~64KB keepalive budget. - Lifecycle: visibilitychange/pagehide vs unload. Sending on
visibilitychange→hiddenandpagehideis reliable (fires on mobile backgrounding and bfcache navigation) and bfcache-friendly;unloadis unreliable (skipped when the OS kills a tab) and disables bfcache. Always use the modern lifecycle events — this matters as much as choosing the Beacon API. - Fire-and-forget limitation. Not being able to read a response is what lets the beacon complete after the page dies — perfect for analytics (no reply needed) but unusable when you need confirmation. For data requiring an acknowledged response, you need the request to happen while the page is alive (not at unload), or use keepalive fetch if the page might survive.
Key Takeaways
- The browser cancels in-flight
fetch/XHR requests when the page unloads, so end-of-session analytics/telemetry sent normally on exit is lost — exactly the most important "they're leaving" request. - The Beacon API (
navigator.sendBeacon(url, data)) solves this by handing the request to the browser, which completes it after the page is gone — asynchronously, non-blocking, fire-and-forget (no readable response), for small payloads (~64KB). - It returns
trueif queued (falseif too large/too many); it always sends a POST (pass aBlobwith a type to control Content-Type) — and you can't read a response, which is what allows it to outlive the page. - Don't fire beacons in
unload—unloadis unreliable (skipped when the OS kills a backgrounded mobile tab) and breaks bfcache; send onvisibilitychange→hidden(the reliable signal the user may be leaving) andpagehide. Getting the lifecycle right matters as much as using the Beacon API. fetchwithkeepalive: trueis the modern, more flexible alternative (custom headers/auth, any method, optional response) sharing the same small budget — use it when you need headers;sendBeaconfor simple fire-and-forget.- Synchronous XHR (the old hack to deliver on unload) freezes the browser and is being removed — the Beacon API is its non-blocking, purpose-built replacement.
What did you think?