A page calls fetch('/collect', { method: 'POST', body }) from inside a pagehide handler, and the request frequently never reaches the server. Why does the browser drop it, and what does navigator.sendBeacon do differently?
answer
- the request belongs to the document
- teardown aborts what is in flight
- keepalive moves ownership to the browser
- fire-and-forget, returns immediately
- works locally, loses data in production
basics
~20 sAn ordinary fetch is tied to the document that started it, so when the document is destroyed the browser aborts the request still in flight. sendBeacon marks the request as keepalive, handing it to the browser to complete after the page is gone.
solid answer
~50 sA plain `fetch()` belongs to the document that issued it. When that document is destroyed — the next navigation commits, the tab closes, the process is reclaimed — the browser terminates the document's outstanding requests, and a POST started microseconds earlier in `pagehide` is exactly the request that gets cut. Whether any bytes arrive is luck: it depends on whether a warm connection existed and how quickly teardown happens, which is why it usually looks fine on localhost and loses data in production. `navigator.sendBeacon()` flags the request as keepalive, which moves ownership from the document to the browser itself: the browser promises to finish the POST regardless of what happens to the page, and the call returns a boolean immediately rather than blocking teardown. `fetch(url, { method: 'POST', body, keepalive: true })` gives the same guarantee if you need headers or the response.
code
javascript · 16 linesconst endpoint = '/collect';
function flush(payload) {
const body = JSON.stringify(payload);
// Preferred: fire-and-forget, cannot delay teardown.
if (navigator.sendBeacon(endpoint, body)) return;
// Fallback with the same lifetime guarantee, plus real headers.
fetch(endpoint, {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}).catch(() => {});
}
addEventListener('pagehide', () => flush({ sessionMs: performance.now() }));go deeper
Recall that a normal fetch fired as the page closes is often cancelled, and that navigator.sendBeacon is the API meant for that moment. Being able to name the right tool is enough here.
Explain the mechanism: requests are tied to the document, teardown aborts them, and the keepalive flag transfers ownership to the browser so the request outlives the page. Name both sendBeacon and fetch with keepalive.
Diagnose the symptom from evidence — a loss rate concentrated on mobile and on cross-page exits — and pair the transport fix with sending at visibilitychange hidden rather than only at teardown.
Argue where fire-and-forget delivery is acceptable at all, and set the rule for the codebase: which payloads must be acknowledged, which may be lossy, and how the resulting loss is measured rather than assumed.
## Requests belong to a document Every request a page starts is associated with that document. When the document is destroyed, the browser terminates the associated requests: sockets are closed, and anything still in flight is aborted. This is normal hygiene — you do not want a closed tab's downloads holding connections open — but it is fatal for a send issued during teardown. The timeline for a `fetch()` inside `pagehide` looks like this: the handler runs, `fetch()` returns a promise, the browser queues the work to open or reuse a connection and write the request, and then the handler returns and teardown proceeds. The write and the teardown are in a race, and the page loses most of the time. Whether the request survives depends on whether a warm keep-alive connection to that origin already existed, how large the body is, how fast the next document commits, and whether the process is being killed outright. The same reasoning applies to any asynchronous work started in a teardown handler. `await`-ing something and then sending is worse, not better: the continuation may never run at all, because there may be no document left to run it in. ## Why it looks like it works in development On a local machine the connection is already open, latency is microseconds, and the request often completes inside the same tick. DevTools with "Preserve log" enabled shows it as green, and the developer concludes the code is correct. Production adds real latency, cold connections, mobile process kills and background discards — and the same code loses a double-digit share of sends. ## What keepalive changes Marking a request keepalive changes its ownership. Instead of living in the document's request set, the request is kept alive by the browser after the document goes away. The browser undertakes to finish it. That is the entire mechanism behind `navigator.sendBeacon()`, which is defined in terms of a keepalive POST: ```js // Both survive the document's destruction: navigator.sendBeacon('/collect', body); fetch('/collect', { method: 'POST', body, keepalive: true }); ``` `sendBeacon` adds one more property that matters during teardown: it is fire-and-forget and returns a boolean synchronously. It cannot delay the navigation and it cannot leave a pending promise that has nowhere to settle. ## What keepalive does not change It is a lifetime guarantee, not a licence. The request still obeys the page's Content Security Policy `connect-src` directive; it is still subject to the same-origin rules and CORS for cross-origin destinations; cookies are attached according to their `SameSite` attributes; and it still counts against the in-flight keepalive body budget, roughly 64 KiB per document, shared between beacons and keepalive fetches. Exceed that and `sendBeacon` returns `false` while `fetch` rejects with a `TypeError`. It also does not make the send observable. A beacon gives you no response; a keepalive `fetch` gives you a promise, but if the document is already gone there is nobody left to read it. ## The fixes people try that do not work - **Synchronous XHR.** It does block until the request completes, which is why it was the old trick, but it freezes the main thread, is deprecated, and browsers have been restricting it precisely in this situation. Do not reach for it. - **A busy-wait loop after the fetch.** It burns CPU during teardown and does not give the network a turn to complete the write. - **Moving the call to `beforeunload`.** Same problem: the request is still owned by a document that is about to be destroyed, and `beforeunload` brings its own reliability problems on mobile. ## The other half of the fix Use the right transport *and* the right moment. `pagehide` is far better than `unload`, but the most reliable last moment on mobile is `visibilitychange` when `document.visibilityState` becomes `"hidden"`, because a backgrounded tab may be discarded without any further event. Combining a keepalive transport with a send at hidden is what turns a lossy telemetry pipeline into a dependable one. ## Recap Ordinary fetches die with their document. Keepalive requests — `sendBeacon` and `fetch(..., { keepalive: true })` — are handed to the browser to finish. Choose between them by whether you need headers and a response, not by whether you need delivery.
- Would moving the call from pagehide to beforeunload fix it?No. The request is still owned by a document that is about to be destroyed, so it is aborted the same way. beforeunload is also unreliable on mobile and adds friction, since browsers only honour it in limited circumstances. The fix is the keepalive transport, not an earlier teardown event.
- People used to solve this with synchronous XMLHttpRequest. Why is that not the answer today?Synchronous XHR blocks the main thread until the response arrives, freezing the browser during navigation. It is deprecated for exactly that reason and browsers have been restricting it in page-dismissal situations. sendBeacon was standardised to replace it, giving the same delivery guarantee without the block.
- Does making the request keepalive let it bypass CORS?No. Keepalive is only a lifetime guarantee. The request is still an ordinary cross-origin request subject to the same-origin policy and CORS, still governed by the page's Content Security Policy connect-src, and cookies still follow their SameSite attributes.
saying these in an interview costs you the question
- Claims fetch always completes once it has been called
- Suggests synchronous XHR as the modern fix
- Awaits inside the teardown handler and expects delivery
- Thinks keepalive exempts the request from CORS or CSP
- Believes a busy-wait loop gives the network time to finish