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.

Page lifecycle events compared Comparison of unload, beforeunload, pagehide and visibilitychange by reliability on mobile and effect on bfcache. Page lifecycle events compared Event Fires reliably on mobile Blocks bfcache Use for unload no yes nothing — remove it beforeunload no in some cases unsaved-changes prompt only pagehide mostly no cleanup that can be undone visibilitychange → hidden yes no sending analytics and saving state

Rapid Diagnosis

  • Find listeners. In DevTools Console, getEventListeners(window).unload lists 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.

Which events fire when a mobile user leaves Timeline of lifecycle events when a mobile user switches apps and the page is later discarded, showing unload never firing. Which events fire when a mobile user leaves User reading page in another app Events page frozen 0s 10s 20s 30s 40s 50s 60s visibilitychange (hidden) fires at 12s; at 50s the tab is discarded without unload ever firing.

Step-by-Step Resolution

1. Send analytics on visibilitychange with sendBeacon

javascript
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

javascript
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.

javascript
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.

unload removal checklist Five steps to remove unload handlers from a site safely. unload removal checklist Inventory listeners Your code and every vendor snippet Analytics to visibilitychange + sendBeacon Server-side de-duplication Cleanup to pagehide Reversible on pageshow when persisted beforeunload only while dirty Added and removed with unsaved state Permissions-Policy unload=() Once nothing depends on unload 1 2 3 4 5

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 sendBeacon or fetch with keepalive.
  • Sending only on pagehide. On mobile, visibilitychange is 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.