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 sample per page lifecycle. Here is how the browser primitives behave, where the traps are, and which of them Monoid's own tracker actually uses.
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, debounce it, or reset your own clock so each firing reports a fresh, non-overlapping segment rather than a growing total. Monoid takes the last approach: the timer behind its dwell-time sample resets on every visibilitychange transition, so a second hidden event later in the same page life reports only the time since the last flush. Monoid listens for visibilitychange (alongside an existing beforeunload fallback) but doesn't add a separate pagehide listener — the two overlap enough on the paths that matter that a second listener would only reintroduce the double-count problem without covering meaningfully more ground.
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. Common wisdom says use Beacon first, fall back to fetch with keepalive — Monoid actually inverts that, for reasons the next section explains.
A minimal, cookieless implementation
Here's a reference pattern combining everything above — sendBeacon first, fetch keepalive as its fallback, triggered by both visibilitychange and pagehide, with a guard so a repeat firing doesn't double-send:
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.
Monoid's own tracker takes a related but distinct path: fetch keepalive only (no sendBeacon — it can't express the mode: 'cors' that a cross-origin install needs), no sent guard, and a timer that resets on every visibility transition instead of firing once and going quiet (consent and Do Not Track gating are omitted below for clarity):
function dur() {
fetch('/duration', {
method: 'POST',
body: JSON.stringify({ site_id: siteId, duration_ms: Date.now() - t }),
headers: { 'Content-Type': 'application/json' },
keepalive: true,
mode: 'cors',
});
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') dur();
t = Date.now();
});
window.addEventListener('beforeunload', dur);
Resetting t on every transition does the same job the sent guard does above, without needing a flag: each call only ever reports the segment since the clock was last reset, so a visibilitychange firing followed by a trailing beforeunload on the same tab-close reports one real segment and one harmless near-zero one, instead of double-counting.
Why this fits privacy-first analytics
Because Monoid never stitches sessions together, each request is a complete, disposable observation. There is no cross-page identifier to protect, so a dropped or duplicate sample costs you one data point, not a corrupted user profile. The visibilitychange-plus-beforeunload pattern Monoid actually ships gives 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.
Comments
Loading comments…