Back to blog

The Visibility State Transition: Measuring Page Views Without Beacons You Can Trust

pagehide, visibilitychange, and the Beacon API each behave differently across browsers. Here's how to record a session end reliably without cookies or persistent identifiers.

Recording that a page was viewed is easy. Recording when a user actually left — reliably, across tab switches, backgrounding, and hard closes — is one of the trickier problems in web analytics. It matters because engagement metrics (dwell time, bounce, scroll depth) depend on capturing a clean end-of-session signal. Get it wrong and you either lose data or inflate it.

Monoid solves this without cookies, localStorage, or any persistent identifier. That constraint actually simplifies things: we never need to reconcile a session across page loads, so all we care about is emitting one accurate final beacon per page lifecycle. Here is how the browser primitives behave, and where the traps are.

Why unload is the wrong event

The historical approach was to listen for unload or beforeunload and fire a synchronous XHR. This is now actively harmful. The unload event is unreliable on mobile: when a user backgrounds a tab and the OS reclaims it, unload frequently never fires. Worse, registering an unload handler disqualifies the page from the back/forward cache (bfcache) in several browsers, hurting navigation performance.

The Page Lifecycle API guidance from web.dev is explicit: treat visibilitychange to hidden as the last reliable event you will observe. Do not rely on unload or pagehide firing on mobile.

The visibilitychange + pagehide pairing

The robust pattern is to listen for visibilitychange and check document.visibilityState === 'hidden', plus pagehide as a secondary signal. Whenever the page transitions to hidden, you flush whatever you need to send. The MDN documentation for the Page Visibility API confirms visibilitychange fires when a tab is backgrounded or the browser is minimised — the closest proxy to "the user stopped looking".

The complication: visibilitychange to hidden can fire multiple times in a session (tab away, tab back, tab away again). So you must make your flush idempotent, or debounce it, or only send a delta since the last flush. Because Monoid holds no per-user state, we send a single self-contained payload and let the edge deduplicate on a request-scoped basis.

Sending data from a dying page: navigator.sendBeacon

You cannot fire an asynchronous fetch during visibilitychange and expect it to complete — the browser may tear the page down first. This is what the Beacon API specification exists for. navigator.sendBeacon(url, data) queues a small POST that the browser guarantees to attempt even after the document is gone, without blocking the main thread or delaying navigation.

Beacon has limits worth respecting:

  • Payload size. The spec allows the user agent to reject beacons over an implementation-defined limit (commonly 64 KB). Keep payloads tiny — Monoid's are a few hundred bytes.
  • Method. Beacons are always POST. If your edge endpoint expects GET, adapt it.
  • No response. You cannot read anything back. Fire and forget.

A modern fallback is fetch(url, { keepalive: true }), which the Fetch standard defines as allowing a request to outlive the document. keepalive gives you headers and methods that Beacon lacks, but shares the same size ceiling. Use Beacon first, fall back to fetch with keepalive.

A minimal, cookieless implementation

let sent = false;
function flush() {
  if (sent) return;
  sent = true;
  const payload = JSON.stringify({
    path: location.pathname,
    // no identifiers, no cookies, no fingerprint
    visibleMs: Math.round(performance.now())
  });
  const ok = navigator.sendBeacon('/collect', payload);
  if (!ok) {
    fetch('/collect', { method: 'POST', body: payload, keepalive: true });
  }
}

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

Note what is absent: no unload listener, so bfcache eligibility is preserved. No stored ID between loads. The visibleMs figure uses performance.now(), a monotonic clock that needs no wall-clock timestamp or persistent state.

Why this fits privacy-first analytics

Because Monoid never stitches sessions together, each beacon is a complete, disposable observation. There is no cross-page identifier to protect, so a dropped beacon costs you one data point, not a corrupted user profile. The visibilitychange-plus-Beacon pattern gives you accurate dwell measurement while keeping the page fast and bfcache-friendly — which itself improves the Core Web Vitals you may be trying to measure.

Measure the leaving, not the person. The browser already gives you everything you need.

Sources

Comments

Loading comments…