navigator.sendBeacon
debt(d7/e3/b3/t7)
Closest to 'only careful code review or runtime testing' (d7). While an ESLint regex could flag unload+fetch patterns, silent failures (payload >64KB, ignored boolean return, missing beacons on mobile) typically only surface through code review or telemetry gap analysis.
Closest to 'simple parameterised fix' (e3). The quick_fix is swapping unload+fetch for navigator.sendBeacon on visibilitychange/pagehide — a small localized refactor of the telemetry flush path, more than one line but confined to one module.
Closest to 'localised tax' (b3). Applies only to the analytics/telemetry layer of a web app; other components are unaffected by the choice.
Closest to 'serious trap' (t7). Per misconception, developers expect a Promise or delivery confirmation but get a synchronous boolean meaning only 'queued'; combined with silent 64KB limits, no header support, and no CORS error surface, it contradicts how fetch behaves elsewhere.
Also Known As
TL;DR
Explanation
navigator.sendBeacon(url, data) schedules an HTTP POST that the browser guarantees to attempt even if the page is being unloaded, hidden, or navigated away from. Unlike fetch, it does not block unload, does not accept custom headers or methods, and returns only a boolean indicating whether the request was successfully queued - not whether it eventually succeeded. This makes it ideal for fire-and-forget telemetry: session analytics, error reports, exit surveys, page-visibility metrics. Payloads count against a shared quota (typically 64 KB across all in-flight beacon and keepalive-fetch requests) and content type is inferred from the data argument (Blob, FormData, URLSearchParams, ArrayBuffer, or string). Because it runs at low priority and cannot be cancelled or awaited, do not use it for anything the application logic depends on. Historically developers tried synchronous XHR in unload handlers to flush analytics; browsers now block that pattern, and sendBeacon is the sanctioned replacement. Pair it with the pagehide or visibilitychange (hidden) events rather than unload, since mobile browsers frequently skip unload entirely - visibilitychange fires reliably when a tab is backgrounded or closed. If you need custom headers (e.g. Authorization), sendBeacon cannot help; use fetch with keepalive: true, which shares the same lifecycle guarantees and the same 64 KB quota but allows full request configuration. Note that CORS applies: cross-origin beacons trigger preflight for non-simple content types, which can silently fail if the server does not respond correctly. Because there is no response callback, debugging failed beacons requires server-side logging or DevTools network inspection with 'preserve log' enabled.
Common Misconception
Why It Matters
Common Mistakes
- Attaching beacon logic to the unload event, which mobile browsers frequently skip - use visibilitychange with document.visibilityState === 'hidden' or pagehide instead.
- Trying to set custom headers like Authorization on sendBeacon - it doesn't support them; use fetch with keepalive: true when headers are required.
- Exceeding the 64 KB payload limit and silently losing beacons because the return value is not checked.
- Using sendBeacon for critical requests whose success the app depends on - it is fire-and-forget with no response.
- Ignoring CORS preflight failures on cross-origin beacons because there is no error callback to surface them.
Avoid When
- The request must succeed and the application logic depends on the response.
- You need custom headers such as Authorization - use fetch with keepalive: true instead.
- Payloads exceed the 64 KB beacon quota.
- You need to observe or retry the request on failure.
When To Use
- Flushing analytics or session metrics when a page is hidden or closed.
- Sending crash reports or error diagnostics from window.onerror handlers.
- Recording last-known page-visibility state on visibilitychange transitions.
- Emitting small telemetry pings that tolerate occasional loss.
Code Examples
// Wrong: unload event is unreliable on mobile, and fetch without keepalive is cancelled
window.addEventListener('unload', () => {
fetch('/analytics', {
method: 'POST',
body: JSON.stringify({ event: 'exit', duration: sessionMs })
}); // often aborted before it leaves the browser
});
// Also wrong: sendBeacon has no way to attach Authorization or other headers
// (the signature is sendBeacon(url, data) — no options object)
navigator.sendBeacon('/analytics', JSON.stringify(payload));
// Auth header silently omitted; server rejects as unauthenticated.
// Flush telemetry when the page is hidden or discarded
function flushAnalytics() {
const payload = JSON.stringify({ event: 'exit', duration: sessionMs });
const blob = new Blob([payload], { type: 'application/json' });
const queued = navigator.sendBeacon('/analytics', blob);
if (!queued) {
// Fallback: fetch with keepalive shares the same lifecycle guarantees
fetch('/analytics', {
method: 'POST',
body: payload,
headers: { 'Content-Type': 'application/json' },
keepalive: true
}).catch(() => {});
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushAnalytics();
});
window.addEventListener('pagehide', flushAnalytics);