Analytics exit events never arrive: fetch in unload vs sendBeacon on pagehide
Our session-end events showed up for about a third of sessions on desktop and almost none on mobile. The tracker posted with fetch inside an unload handler that browsers cut short or never fire. Flushing on visibilitychange with sendBeacon brought delivery above 97 percent.
Our product dashboard reported an average engaged time per article that looked too good: the median session was "still open" when the data warehouse closed the day. The session-end event, which carries the final scroll depth and active seconds, arrived for roughly 34 percent of desktop sessions and under 6 percent of mobile sessions. Page-view events, sent while the page was alive, arrived for nearly everyone.
In Chrome DevTools, with "Preserve log" enabled on the Network panel, closing the tab showed the request to our collector as:
POST https://collect.example.com/v1/events (canceled)
Status: (canceled) Type: fetch Initiator: tracker.js:212
On iOS Safari, using Web Inspector from a Mac, the request never appeared at all when we swiped the app away or switched to another app and let the OS discard the tab.
Reproducing it
- Open any article with the tracker loaded and DevTools open, "Preserve log" on.
- Scroll for a few seconds, then close the tab with Ctrl+W, or navigate to another site by typing in the address bar.
- Watch the collector request: it is either cancelled or never sent.
On mobile the reliable repro is: open the article, press the home button, then remove the browser from the app switcher. No request is ever made.
What we ruled out
- Ad blockers. We tested in clean profiles and on a device with no extensions. Same loss rate, and page-view events from the same collector host arrived fine.
- CORS preflight. Our payload was
application/json, which triggers a preflight. That adds a round trip and makes things worse, but the event was lost even when we switched totext/plainwhile still usingunload. - Collector errors. Load balancer logs showed no 4xx or 5xx spikes. The requests simply never reached us.
Root cause
The tracker buffered events in memory and flushed them from an unload listener with a normal fetch(). A normal fetch is tied to the document; when the document is torn down, the browser cancels it. On top of that, mobile browsers frequently never fire unload (or beforeunload) at all: the page is hidden first, then frozen or discarded by the OS without any further event. Our only flush point was an event that, on mobile, effectively does not exist.
What is happening under the hood
The Page Lifecycle model gives one event that browsers fire reliably before a page may disappear: visibilitychange with document.visibilityState === "hidden". It fires when the user switches tabs, minimizes, locks the phone, moves to another app, or starts closing the tab. After that point the page may be frozen, put into the back/forward cache (bfcache), discarded, or unloaded, and there is no guarantee that any further event fires.
unload and beforeunload are worse in several ways:
- They are not fired when a mobile OS kills a backgrounded tab.
- A page with an
unloadlistener is not eligible for the bfcache in Firefox and in older Chrome versions, so it also costs navigation speed. - Chrome is gradually deprecating
unloadand already lets sites opt out early with thePermissions-Policy: unload=()header.
Even when unload fires, a regular fetch() or XHR started there is cancelled as the document goes away. Two APIs exist precisely to outlive the page:
navigator.sendBeacon(url, data)queues a small POST that the browser delivers in the background. It returnstrueif the data was queued andfalseif it was refused (usually because the payload is too large).fetch(url, { keepalive: true })behaves similarly but lets you set headers and method. The body size limit is shared with beacons: the in-flight keepalive quota is 64 KiB per fetch group.
There is one more subtlety: a beacon sent with a Blob of type application/json is a non-CORS-safelisted request, so cross-origin it needs a preflight. Sending the body as text/plain (a plain string) avoids that and keeps delivery to a single request.
Environment
| Component | Version / value |
|---|---|
| Browsers in sample | Chrome 145, Safari 18 (iOS and macOS), Firefox 147 |
| Tracker | In-house tracker.js, ES2020, bundled by Vite 6 |
| Collector | collect.example.com, Go service behind an ALB |
| Original flush | window.addEventListener("unload", flush) with fetch() |
| Payload | JSON array, 1-40 events, typically 2-6 KB |
Minimal reproduction
// tracker.js (broken)
const queue = [];
export function track(name, props = {}) {
queue.push({ name, props, ts: Date.now() });
}
window.addEventListener("unload", () => {
// cancelled on desktop when the document dies,
// never reached on mobile when the OS discards the tab
fetch("https://collect.example.com/v1/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(queue),
});
});
Who is affected
Any client-side analytics, RUM, or "save draft on exit" code that relies on unload or beforeunload plus a normal request. The loss is worst on mobile, in single-tab PWAs, and for users who close tabs rather than navigate. Code that already flushes on visibilitychange with beacons is fine.
The fix
We changed three things: the flush trigger, the transport, and the payload contract.
- Flush when the page becomes hidden, and also on
pagehideas a fallback for older Safari versions that did not always firevisibilitychangeon tab close. - Use
sendBeaconwith atext/plainbody, and fall back tofetchwithkeepaliveif the beacon is refused. - Make each flush idempotent: every event gets a client-generated
event_id, and the collector deduplicates. A page that is hidden and then shown again may flush twice, which is now harmless.
// tracker.js (fixed)
const ENDPOINT = "https://collect.example.com/v1/events";
const MAX_BYTES = 60 * 1024; // stay under the 64 KiB keepalive quota
let queue = [];
export function track(name, props = {}) {
queue.push({ event_id: crypto.randomUUID(), name, props, ts: Date.now() });
}
function send(batch) {
const body = JSON.stringify(batch);
if (body.length > MAX_BYTES && batch.length > 1) {
const mid = Math.ceil(batch.length / 2);
send(batch.slice(0, mid));
send(batch.slice(mid));
return;
}
// a string body is sent as text/plain, so no CORS preflight
const queued = navigator.sendBeacon(ENDPOINT, body);
if (!queued) {
fetch(ENDPOINT, { method: "POST", body, keepalive: true, mode: "no-cors" })
.catch(() => {});
}
}
function flush() {
if (queue.length === 0) return;
const batch = queue;
queue = [];
send(batch);
}
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flush();
});
window.addEventListener("pagehide", flush);
On the collector, we accept text/plain, parse it as JSON, and upsert on event_id:
// collector handler excerpt
func handleEvents(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 64<<10))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
var events []Event
if err := json.Unmarshal(body, &events); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
store.UpsertByEventID(r.Context(), events) // ON CONFLICT (event_id) DO NOTHING
w.WriteHeader(http.StatusNoContent)
}
Finally we removed every unload listener from the codebase and sent Permissions-Policy: unload=() from the CDN, so third-party scripts cannot quietly add one back and knock pages out of the bfcache.
Alternatives and trade-offs
- Periodic flushing while visible. Sending every 10-15 seconds reduces what is at risk on exit. We kept a 15-second interval flush in addition to the hidden flush; it costs a few extra requests per session.
fetchwithkeepaliveonly. Fine if you need custom headers (for example an auth token), but those headers usually trigger a preflight, which is exactly what you do not want during teardown.- Persisting to
localStorageand sending on next visit. Useful for "save draft" features; useless for analytics of users who never return.
How to test the fix
Manual check in Chrome: open DevTools, Network panel, "Preserve log" on, filter by collect.example.com, then close the tab with another tab focused. The request now shows type ping with status 204.
We also added a Playwright test. Headless browsers do not hide pages the way a real tab switch does, so the test records beacon calls and then simulates the hidden state explicitly:
// tests/beacon.spec.ts
import { test, expect } from "@playwright/test";
test("flushes queued events when the page is hidden", async ({ page }) => {
await page.addInitScript(() => {
(window as any).__beacons = [];
navigator.sendBeacon = (url, data) => {
(window as any).__beacons.push({ url: String(url), data: String(data) });
return true;
};
});
await page.goto("http://localhost:4173/article/sample");
await page.evaluate(() => (window as any).__tracker.track("scroll_depth", { pct: 80 }));
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
});
const beacons = await page.evaluate(() => (window as any).__beacons);
expect(beacons).toHaveLength(1);
expect(beacons[0].data).toContain("scroll_depth");
});
npx playwright test tests/beacon.spec.ts --project=chromium
Confirming in production
We compared the ratio of session_end to page_view per platform for seven days before and after the release:
SELECT platform,
count(*) FILTER (WHERE name = 'session_end')::float
/ nullif(count(*) FILTER (WHERE name = 'page_view'), 0) AS end_ratio
FROM events
WHERE ts >= now() - interval '7 days'
GROUP BY platform;
Desktop went from 0.34 to 0.98 and mobile from 0.06 to 0.97. Duplicate deliveries, caught by the event_id upsert, were about 2.1 percent of exit batches, which matches users switching tabs and coming back.
Regression guard
- An ESLint
no-restricted-syntaxrule fails the build onaddEventListener("unload"andaddEventListener("beforeunload"outside the one file that legitimately prompts about unsaved form changes. - The Playwright test above runs in CI on Chromium and WebKit.
- A dashboard alert fires if the desktop
end_ratiodrops below 0.9 for a day.
Security implications
Beacons cannot carry custom headers, so they must not carry credentials or tokens in the body or URL; ours carry only an anonymous session id. Because the collector now accepts text/plain cross-origin without a preflight, it must treat every body as untrusted: we cap the body at 64 KiB, validate the schema, and rate-limit per IP to blunt junk submissions. Nothing in the response is readable by the page, so there is no data exposure path. The Permissions-Policy header slightly reduces what third-party scripts can do, which is a small hardening gain.
Prevention checklist
- Treat
visibilitychangeto hidden as the last reliable moment to send data. - Use
sendBeaconorfetchwithkeepalivefor anything sent during teardown, and keep payloads under 64 KiB. - Avoid preflights on exit requests: plain string bodies, no custom headers.
- Make exit events idempotent with a client-side id.
- Ban
unloadlisteners and audit third-party tags for them.
References
- MDN, Navigator.sendBeacon(): https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon
- MDN, Document visibilitychange event: https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event
- Chrome for Developers, Page Lifecycle API: https://developer.chrome.com/docs/web-platform/page-lifecycle-api
- Chrome for Developers, Deprecating the unload event: https://developer.chrome.com/docs/web-platform/deprecating-unload
- WHATWG Fetch Standard, keepalive: https://fetch.spec.whatwg.org/#request-keepalive-flag
Add a label
Embed this post
<iframe src="https://www.hibug.me/analytics-events-lost-on-page-unload" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.
Related Stories
Invoice PDFs showed garbage for accented customer names: jsPDF standard fonts and embedding TTF
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.