skip to content

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%

answer

  1. mobile tabs are killed, not unloaded
  2. hidden always happens first
  3. pagehide as the backstop
  4. flush incrementally, not once
  5. the user can come back

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.

solid answer

~50 s

The `unload` event is not guaranteed. On mobile the common exit is not a navigation at all — the user switches apps or goes to the home screen, the tab is backgrounded, and the browser later discards or kills it without ever running an unload handler. Browsers have also been restricting and deprecating `unload` precisely because it is unreliable and because registering it harms other behaviour like the back/forward cache. The last moment you can count on is when the page becomes hidden: listen for `visibilitychange` and act when `document.visibilityState === 'hidden'`, with `pagehide` as a backstop for the actual teardown path. Send with `navigator.sendBeacon()` or `fetch(..., { keepalive: true })`, because an ordinary fetch there is cancelled with the document. And do not treat hidden as one-shot — the user may return, so flush what you have, mark it sent, and keep going.

code

javascript · 16 lines
javascript
let buffer = [];
let seq = 0;

function flush() {
  if (buffer.length === 0) return;
  const body = JSON.stringify({ sessionId, seq: seq++, events: buffer });
  buffer = [];
  if (!navigator.sendBeacon('/collect', body)) {
    fetch('/collect', { method: 'POST', body, keepalive: true }).catch(() => {});
  }
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush);

go deeper

for a junior

Know that unload often does not run, especially on phones, and that the page becoming hidden is the moment to send. Naming visibilitychange and sendBeacon is a good answer here.

for a middle

Explain why the mobile exit path skips unload entirely and how visibilitychange with document.visibilityState hidden plus pagehide covers both exits, paired with a keepalive transport.

for a senior

Diagnose from the data — desktop-only session ends, a missing duration tail — then design the fix: incremental flushes, a session id and sequence number, idempotent server writes, and no async work in the handler.

for a principal

Own the measurement contract: what loss rate the business will tolerate, whether the pipeline sessionizes server-side instead of trusting a terminal event, and how you verify the loss rate rather than assuming the fix worked.

## Why unload never fires Desktop developers picture leaving a page as a navigation: click a link, `unload` fires, done. On mobile that is the minority path. The usual exits are switching to another app, going to the home screen, or locking the screen. In all of them the tab is simply backgrounded. Some time later — seconds or hours, under memory pressure you do not control — the browser discards or kills the tab. There is no chance to run JavaScript at that point, so no unload handler runs, and everything queued for "the end of the session" is lost. This produces a very recognisable data signature: session-end events present for desktop and largely absent for mobile, and a session-duration distribution that is missing its tail. If you see that, the send point is the bug, not the transport. Two further facts push in the same direction. Browsers have been actively restricting and deprecating `unload`, treating it as legacy. And registering an unload handler at all is bad for the page's eligibility for other browser optimisations on back/forward navigation, so it costs you something even when it does fire. ## The event that does fire The reliable signal is the transition to hidden. Every path out of a page — navigating away, closing the tab, switching apps, locking the screen — passes through the page becoming hidden first. That transition is delivered as `visibilitychange`, and you act on it when `document.visibilityState` reads `"hidden"`: ```js document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flush(); }); ``` `pagehide` is the useful backstop: it fires on the teardown path, including when the page is being put aside for back/forward navigation. Registering both and de-duplicating is the standard belt-and-braces pattern. `beforeunload` is not part of the answer — it is for prompting the user about unsaved work, it is throttled and conditioned by browsers, and it is no more reliable than `unload` on mobile. ## Hidden is not the end The important consequence of moving the send earlier is that hidden is not terminal. The user may switch back and the page becomes visible again, possibly many times in one session. Your flush therefore must be written as "send what has accumulated and clear the buffer", not "send the session summary once". Practically: - Keep a buffer of pending events; on hidden, serialize and send it, then empty it. - Make each payload independently meaningful so the server can stitch a session from several partial sends using a session id. - Include a monotonically increasing sequence number so the server can spot gaps and order the fragments. - Make the write idempotent server-side, because a page that hides and shows repeatedly will produce near-duplicate sends, and because a client can never confirm delivery. ## The transport still matters Sending at the right moment does not save you if the transport dies with the document. From a hidden or teardown handler use `navigator.sendBeacon()` — fire-and-forget, returns a boolean, cannot block — or `fetch(url, { keepalive: true })` when you need JSON headers or want to read the response in the case where the page survives. A plain `fetch()` is owned by the document and gets aborted. Do no asynchronous work before the send: no `await`, no timer, no `requestIdleCallback`; the continuation may never run. Keep the payload inside the in-flight keepalive budget of roughly 64 KiB, which is another argument for flushing incrementally rather than saving everything for the exit. ## Diagnosing it in the wild Compare event counts by platform and by exit path. Instrument the client to report which handler produced each flush, so you can measure how often `unload` fires at all versus hidden. Server-side, count sessions with a start but no end. Once the send moves to hidden, that gap closes sharply — and if it does not, the remaining loss is transport or size, which is where the `false` returns and `TypeError` rejections you should already be counting will tell you the rest. ## Recap Unload is a desktop-shaped assumption. Send when the page goes hidden, back it up with `pagehide`, use a keepalive transport, flush incrementally, and design the server for duplicates you cannot prevent.

  • Why not just use beforeunload instead of unload?
    It has the same mobile problem — an app switch or a discarded tab never reaches it — and browsers condition and throttle it because its real purpose is prompting about unsaved work. Swapping one teardown event for another does not address the fact that teardown often never happens.
  • The page can go hidden and visible many times. How do you avoid double-counting?
    Treat each flush as sending and clearing a buffer rather than sending a session summary. Give every payload a session id and an incrementing sequence number, and make the server write idempotent by that pair. The server then stitches fragments into a session and discards repeats safely.
  • Should you ever do async work before the send in a hidden handler?
    No. Once the page is hidden it may be discarded at any moment, and a continuation after an await, a timer, or an idle callback may never run. Serialize synchronously and hand the payload to sendBeacon or a keepalive fetch in the same turn as the event.

saying these in an interview costs you the question

  • Treats unload as guaranteed to run
  • Reaches for beforeunload as the reliable alternative
  • Assumes hidden means the session has ended
  • Awaits async work inside the exit handler
  • Buffers the whole session for one final send

context