Web Development Bug Report

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.

John Nguyen John Nguyen @devbugs · March 14, 2026 · 10 min read · 101 Views
Banner: analytics exit events lost when a browser tab closes, fixed with sendBeacon

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

  1. Open any article with the tracker loaded and DevTools open, "Preserve log" on.
  2. Scroll for a few seconds, then close the tab with Ctrl+W, or navigate to another site by typing in the address bar.
  3. 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 to text/plain while still using unload.
  • 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 unload listener is not eligible for the bfcache in Firefox and in older Chrome versions, so it also costs navigation speed.
  • Chrome is gradually deprecating unload and already lets sites opt out early with the Permissions-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 returns true if the data was queued and false if 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.

  1. Flush when the page becomes hidden, and also on pagehide as a fallback for older Safari versions that did not always fire visibilitychange on tab close.
  2. Use sendBeacon with a text/plain body, and fall back to fetch with keepalive if the beacon is refused.
  3. 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.
  • fetch with keepalive only. 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 localStorage and 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-syntax rule fails the build on addEventListener("unload" and addEventListener("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_ratio drops 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 visibilitychange to hidden as the last reliable moment to send data.
  • Use sendBeacon or fetch with keepalive for 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 unload listeners and audit third-party tags for them.

References

Add a label

#javascript #analytics #sendbeacon #page-lifecycle #bfcache
Written by
John Nguyen @devbugs

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.