skip to content

sendBeacon and keepalive Requests

You will learn how to get analytics or session data out of a page that is being closed, and why an ordinary fetch there gets cancelled. Interviewers ask this whenever telemetry or 'save on exit' comes up.

on this pageshow

questions

5

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?

level: middleimportance: must knowfreq 65%

answer

  1. the request belongs to the document
  2. teardown aborts what is in flight
  3. keepalive moves ownership to the browser
  4. fire-and-forget, returns immediately
  5. works locally, loses data in production

basics

~20 s

An 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 s

A 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 lines
javascript
const 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

Session-end analytics is missing for a large share of mobile visits, and the app sends its final payload from a window 'unload' handler. Explain why that handler is unreliable and where the send should happen instead.

level: seniorimportance: must knowfreq 55%

basics

~20 s

On mobile a tab is usually backgrounded and later discarded or killed, so unload frequently never fires at all. Send at visibilitychange when the state becomes hidden, with pagehide as a backstop, using sendBeacon or a keepalive fetch.

open as a page

What does the browser's navigator.sendBeacon(url, data) call return, and what does that return value actually tell you about whether the server received the data?

level: juniorimportance: should knowfreq 45%

basics

~20 s

navigator.sendBeacon returns a boolean immediately: true means the browser queued the data for sending, false means it refused to queue it, usually because the payload was too large. True is not a delivery receipt, and no response is ever readable.

open as a page

What does the keepalive: true option on the browser's fetch() give you that navigator.sendBeacon does not, and what limit does the browser place on keepalive request bodies?

level: middleimportance: should knowfreq 45%

basics

~20 s

fetch with keepalive gives the same survive-the-page guarantee as sendBeacon while letting you choose the method, set headers such as Content-Type, control credentials, and read the response. The cost is a shared in-flight body budget of about 64 KiB per document.

open as a page

Your telemetry endpoint expects JSON, but requests sent with navigator.sendBeacon('/collect', JSON.stringify(payload)) arrive with Content-Type: text/plain;charset=UTF-8. Why does that happen, and what are your options?

level: middleimportance: nice to knowfreq 35%

basics

~20 s

sendBeacon accepts no headers, so the Content-Type is derived from the body type, and a string always becomes text/plain;charset=UTF-8. Either parse text/plain on the server, send a Blob typed application/json, or use fetch with keepalive and explicit headers.

open as a page