When a user closes a tab, submits a form, or clicks an external link, you often need to send one last piece of data — a session duration, the last scroll position, an unhandled error report, an A/B test completion event. The natural instinct is to fetch() inside a beforeunload handler. The problem: modern browsers cancel in-flight requests the moment the page begins to unload. Your data never arrives, and you never find out.
Why fetch in beforeunload fails
The browser's job during a page unload is to navigate away as fast as possible. Keeping a page alive to wait for a network response directly conflicts with that goal. Modern browsers — Chrome, Firefox, Safari — cancel async requests that are in-flight during unload. The beforeunload handler runs, fetch() is called, and the request is silently aborted before it reaches the server.
// This looks correct, but the request is often cancelled
window.addEventListener('beforeunload', () => {
fetch('/api/session-end', {
method: 'POST',
body: JSON.stringify({ duration: getSessionDuration() }),
});
// The browser navigates away. Fetch is cancelled. Data is lost.
});
The old workaround was a synchronous XMLHttpRequest, which blocks the page from closing until the request completes. That approach worked — and also made every tab close feel sluggish. Browsers deprecated synchronous XHR in unload contexts because it reliably degraded user experience. Chrome has been logging warnings about it since 2019.
The API
navigator.sendBeacon() is built for exactly this case:
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon('/api/session-end', JSON.stringify({
duration: getSessionDuration(),
page: location.pathname,
}));
}
});
The browser queues the request and delivers it asynchronously, even after the page has been discarded. The tab can close, the browser can background-suspend the tab, the user can navigate away — the beacon is still delivered. You get no response object back; sendBeacon returns true if the data was successfully queued, false if the payload is too large or the browser rejected it. There is no callback, no .then(), no await. That's intentional: the call is fire-and-forget by design.
Why visibilitychange instead of beforeunload
beforeunload has a reliability problem beyond network requests: on mobile, it often doesn't fire at all. When the OS suspends a browser tab or the user swipes the app away, there's no beforeunload event — the page just disappears.
document.visibilitychange with document.visibilityState === 'hidden' is more reliable:
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// Page is backgrounded, tab switched, device locked, or tab closing
navigator.sendBeacon('/api/beacon', payload);
}
});
The hidden event fires whenever the page leaves the foreground — including on mobile when the user switches apps. It's not a perfect signal for "tab is closing specifically," but it's the closest reliable approximation, and it fires in situations where beforeunload is completely absent.
What you can send
sendBeacon accepts a BodyInit — the same types that fetch accepts as a body:
// String (Content-Type: text/plain)
navigator.sendBeacon('/api/log', 'user-exited');
// JSON string (still text/plain — set Content-Type via Blob)
const blob = new Blob([JSON.stringify({ event: 'exit', ts: Date.now() })], {
type: 'application/json',
});
navigator.sendBeacon('/api/log', blob);
// FormData (Content-Type: multipart/form-data)
const form = new FormData();
form.append('event', 'exit');
navigator.sendBeacon('/api/log', form);
// URLSearchParams (Content-Type: application/x-www-form-urlencoded)
navigator.sendBeacon('/api/log', new URLSearchParams({ event: 'exit' }));
The Blob approach with an explicit type is the most useful — it lets you send JSON while controlling the Content-Type header so your server receives it correctly. Without the Blob wrapper, a stringified JSON payload arrives as text/plain, and any middleware expecting application/json will reject or misparse it.
The keepalive fetch alternative
If you need a response from the server, or you want to add custom headers, fetch with keepalive: true is the modern alternative:
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
fetch('/api/session-end', {
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/json', 'X-Auth': token },
body: JSON.stringify({ duration: getSessionDuration() }),
});
}
});
keepalive: true tells the browser to keep the request alive even if the page is discarded. Unlike sendBeacon, you can set headers and use any HTTP method. The trade-off: total keepalive payload per page is capped at 64 KB across all requests. sendBeacon has the same limit. For analytics payloads, 64 KB is effectively unlimited — but for large error dumps, be aware of it.
Use sendBeacon when you don't need custom headers and want the simplest possible fire-and-forget. Use keepalive fetch when you need headers, a specific HTTP method, or want to handle a response.
Browser support
navigator.sendBeacon() is Baseline 2022: Chrome 39 (2014), Firefox 31 (2014), Safari 11.1 (2018). It has been available in every supported browser for years and works in Web Workers. There is nothing to polyfill for any currently-maintained target.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The takeaway
Search your codebase for fetch or XMLHttpRequest inside beforeunload or unload handlers. If you find them, the data they send is being silently dropped in a meaningful percentage of page exits. Replace them with navigator.sendBeacon() on visibilitychange, or a keepalive fetch if you need headers. The API is one line, the delivery is reliable, and the browser handles the timing without blocking navigation.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
- 💼 LinkedIn — linkedin.com/in/parsa-jiravand
- ✉️ Email (work & contract inquiries): bestpractice2026@gmail.com
Top comments (0)