How to Replace unload Handlers So Pages Can Use bfcache
This guide covers the single most common bfcache blocker, within Back/Forward Cache (bfcache) in Advanced Caching Strategies & CDN Architecture. The unload event has two problems. It makes pages ineligible for the back/forward cache in Chromium desktop and Firefox, because code written for unload assumes the page is gone forever. And it is unreliable: on mobile, pages are frequently discarded or the app is backgrounded without unload ever firing, so the analytics and cleanup code people put there often never runs.
The replacements are better on both counts. visibilitychange (when the state becomes hidden) fires whenever the user leaves the page — switching tabs, backgrounding the app, navigating away — and is the most reliable last chance to send data. pagehide fires when the page is being navigated away from, including when it enters bfcache, with event.persisted telling you which. Neither blocks bfcache.
Rapid Diagnosis
- Find listeners. In DevTools Console,
getEventListeners(window).unloadlists registered handlers (Chromium); the Elements panel's Event Listeners tab shows them with source links. - Search code and vendors for
unload,onunload, and libraries known to use it in older versions (some analytics, session replay and A/B tools). - Check the bfcache test. DevTools reports "unload handler" with the frame that registered it.
- Check analytics completeness. If session-end events are missing for many mobile sessions, they are probably sent from
unload.
Root Cause Analysis
1. Analytics flushing. The classic use: send buffered events or a session-end beacon in unload.
2. Cleanup. Closing connections, releasing locks, clearing timers — work that is pointless on a real unload and harmful if the page is restored.
3. Saving state. Writing form drafts or UI state to storage on exit.
4. Third-party defaults. Vendor snippets added years ago that register unload without your knowledge.
Step-by-Step Resolution
1. Send analytics on visibilitychange with sendBeacon
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && queue.length) {
navigator.sendBeacon('/analytics', JSON.stringify(queue.splice(0)));
}
});
// trade-off: visibilitychange fires on every tab switch, so a long visit may
// send several beacons. De-duplicate server-side by event id rather than
// trying to send exactly once from the client.
Expected outcome: more complete data on mobile and no bfcache block.
2. Move cleanup to pagehide and make it reversible
addEventListener('pagehide', (event) => {
stopPolling();
if (event.persisted) {
// Page may be restored: keep in-memory state, just pause activity.
}
});
addEventListener('pageshow', (event) => { if (event.persisted) startPolling(); });
// trade-off: reversible cleanup is more code than fire-and-forget teardown.
// The alternative — not cleaning up at all — is fine for most pages, because
// the browser tears everything down on a real unload anyway.
Expected outcome: cleanup happens without blocking restoration, and restored pages resume.
3. Save state continuously, not on exit
Write drafts and UI state when they change (debounced) or on visibilitychange, instead of relying on an exit event.
4. Use beforeunload only conditionally
If you need an "unsaved changes" prompt, add the beforeunload listener only while there are unsaved changes and remove it as soon as they are saved.
function onDirtyChange(dirty) {
if (dirty) addEventListener('beforeunload', warn);
else removeEventListener('beforeunload', warn);
}
function warn(e) { e.preventDefault(); }
// trade-off: the prompt protects users from losing work but makes that page
// ineligible for bfcache while it is dirty — acceptable, since it is rare.
5. Block unload site-wide with Permissions-Policy
Once your code and vendors no longer rely on it, Permissions-Policy: unload=() prevents any frame from registering unload handlers.
Verification
Use getEventListeners(window) to confirm no unload listeners remain, run the DevTools bfcache test, and compare analytics completeness for mobile sessions before and after — session-end events should increase. The bfcache restoration rate in RUM should rise by roughly the share of misses previously attributed to unload.
Worked Example: Session Replay Vendor
A product team found that 70% of their bfcache misses were caused by an unload handler registered by a session-replay script. The vendor's current version supported visibilitychange-based flushing behind a configuration flag; enabling it removed the handler. Session-replay completeness on mobile actually improved, because the old unload flush had frequently not fired when users switched apps. The restoration rate rose from 22% to 71% on the affected templates.
Common Mistakes
- Moving unload code to beforeunload. That just trades one problem for another.
- Using synchronous XHR in exit handlers. It is deprecated, blocks navigation and is ignored by many browsers; use
sendBeaconorfetchwithkeepalive. - Sending only on pagehide. On mobile,
visibilitychangeis more reliable; use both if needed and de-duplicate. - Forgetting iframes. An unload handler in any same-page frame blocks the whole page.
Edge Cases
Single-page apps. Route changes do not fire pagehide or visibilitychange; flush analytics on route changes as well as on hide.
Prerendered pages. A prerendered page that is never activated fires no visibilitychange to hidden from the user's perspective; do not count prerender-only sessions as real visits.
fetch with keepalive. An alternative to sendBeacon that allows custom headers and methods, with a combined body-size limit across in-flight keepalive requests.
Old browsers. pagehide and visibilitychange are universally supported in modern browsers; no fallback to unload is needed.
Migrating a Codebase Safely
Removing unload usually touches analytics, persistence and cleanup code owned by different teams. A safe sequence: first add the replacement (visibilitychange/pagehide) alongside the existing unload handler for one release, tagging events with which handler sent them, so you can confirm the new path delivers at least as much data. Then remove the unload handler and keep the tag for a few weeks to watch for gaps. Finally, add the unload=() Permissions-Policy in report-only fashion where your monitoring supports it, or on a subset of pages first, to catch any third party that still depends on unload before enforcing it everywhere.
FAQ
Is unload being removed from browsers?
Chrome has been gradually deprecating it, with a plan to stop firing unload by default and the unload=() Permissions-Policy available to opt out early. Treat it as obsolete and remove it now.
Does visibilitychange fire when the user closes the tab?
Yes — the page becomes hidden before it is unloaded, so visibilitychange fires. It also fires in cases where unload does not, such as mobile app switching followed by a discard.
What is the size limit for sendBeacon?
Browsers cap queued beacon data (commonly around 64KB per page, shared across beacons). sendBeacon returns false when a payload cannot be queued; fall back to fetch with keepalive or trim the payload.
Do I need both pagehide and visibilitychange?
For analytics, visibilitychange alone is usually sufficient and most reliable. Use pagehide for lifecycle-specific logic, such as pausing activity before the page enters bfcache.
How do I find which vendor registered an unload handler?
In DevTools, the Event Listeners panel and getEventListeners(window).unload show the handler function with a link to its source file; the file's origin identifies the vendor.
Related
- Sending web vitals with sendBeacon on visibility change — the same pattern for RUM beacons.
- Fixing bfcache eligibility blockers — the full list of blockers.
- Deferring non-critical analytics scripts safely — analytics loading that pairs with these sending patterns.