Chrome 155 Removed unload. Replace It Without Losing Analytics or bfcache

Chrome 155 completes the removal of unload as a default browser callback. For Chrome and other Chromium-based browsers, the handler no longer fires by default. That is a breaking change for code that used the event to save state, close resources, or send analytics. It is also an opportunity to remove a lifecycle assumption that was already unreliable on mobile and hostile to the back/forward cache.
Chrome's own guidance is plain: do not use unload on modern browsers. A site that waits for the final moment of a page's life is already too late in many normal user journeys. Tabs are backgrounded, frozen, discarded, restored from history, or closed by an operating system without the tidy sequence a desktop test often suggests.
What Changed in Chrome 155
Chrome's announcement says the deprecation is complete in Chrome 155. By default, unload handlers no longer fire. Chrome spent years rolling the change out because web sites had accumulated real dependencies on the event, even though it could never provide the guarantee many implementations assumed.
The announcement reports that unload usage fell from 18% of navigations to under 4%. It also says unload went from the sole bfcache blocker on 6% of navigations to around 1%. Those figures are Chrome-wide observations, not a prediction for any one application, but they explain why this is more than a syntax cleanup.
Do Not Confuse Chrome 155 With Every Browser
The browser platform is not one runtime. Chrome says unload may still fire for Chrome users on iOS because that browser uses WebKit. Other browsers can make their own implementation choices. The practical lesson is not to add browser detection and keep the old callback alive. It is to remove the dependency on a final callback that was never dependable everywhere.
Temporary Opt-Outs Are Not a Migration Plan
Chrome documents Permission Policy and enterprise options that can keep unload available for a small number of sites. They are useful when a large organization needs time to fix a verified compatibility issue. They are not evidence that an event has become reliable again.
Put an owner, a deadline, and a removal test on any opt-out. Otherwise the exception will quietly become part of the architecture.
Inventory Every Registration
Start with a source search. Legacy code may register the handler directly, through a shared analytics library, or through a framework wrapper that changed names several times.
rg -n "addEventListener\(['\"]unload|onunload|onUnload" src app pages public
rg -n "beforeunload|pagehide|visibilitychange" src app pages public
Search built output too when third-party widgets and tag managers are in scope. A clean application repository does not prove that code added at runtime is clean.
Classify the Work Before Replacing It
Each unload handler needs a job description. The replacement depends on what it did, not on which event looks most similar.
| Existing job | Preferred approach |
|---|---|
| Save small local UI state | visibilitychange when the document becomes hidden |
| Send a small best-effort analytics event | visibilitychange plus sendBeacon() or fetch(..., { keepalive: true }) |
| Warn about unsaved user edits | Add beforeunload only while edits are unsaved |
| Release resources before a page is frozen | freeze and pagehide, with bfcache-aware cleanup |
| Restore behavior after back navigation | pageshow, inspecting event.persisted |
This table is not a license to replace every listener with the same snippet. A large upload, a payment action, and a locally cached draft have different delivery requirements. Put durable business actions behind an explicit save or server acknowledgment, not a browser-exit hook.
Use Visibilitychange for the Last Reliable Moment
Chrome's Page Lifecycle API guidance calls the transition to hidden the likely end of a session. That state is observable when a user switches tabs, navigates away, closes a tab, minimizes the browser, or switches mobile apps. It is the point to persist small amounts of application state and send unsent analytics.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
persistDraftLocally();
navigator.sendBeacon('/analytics/page-hidden', JSON.stringify({
path: location.pathname,
at: Date.now(),
}));
});
The snippet has limits. sendBeacon() is designed for small, best-effort data. A server must tolerate retries or duplicates, and the client should not treat a beacon as a receipt for work that must be completed. For a larger or important action, save earlier while the page is active and show the user whether it succeeded.
Make Analytics Idempotent
Pages can become hidden and visible more than once. A user can navigate back from bfcache. Network delivery can fail. Any analytics endpoint receiving lifecycle events should accept repeated events without turning each one into a new user session or a false conversion.
Include an event identifier or a stable navigation identifier, and decide at the server whether repeated input updates an existing record or is retained as a separate event. The right decision depends on the question your analytics answers. What matters is making the choice deliberately.
Keep Beforeunload Narrow
beforeunload has a legitimate job: warn a user about unsaved changes they would otherwise lose. It is not a general substitute for unload.
Chrome recommends adding the listener only when an application has unsaved changes and removing it as soon as the changes are saved. This reduces needless prompts and avoids the performance and reliability costs of a permanent listener.
const warnBeforeLeaving = (event) => {
event.preventDefault();
event.returnValue = true;
};
function setDirty(isDirty) {
window[isDirty ? 'addEventListener' : 'removeEventListener'](
'beforeunload',
warnBeforeLeaving,
);
}
Do not rely on this listener to send data. Browsers can suppress it, require user interaction, or choose a bfcache path. Its value is the user-facing warning while unsaved work still exists.
Handle bfcache as a Normal Navigation Path
The back/forward cache stores a page in memory so that the browser can restore it quickly during history navigation. A restored page is not a new page load. It may still hold JavaScript state, open timers, and UI that needs a small refresh rather than a full reinitialization.
Use pageshow to distinguish a restored page when your app needs to do something specific.
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return;
refreshTimeSensitiveUI();
resumePausedPolling();
});
Avoid treating every pageshow as permission to re-run mutations. A page restored from bfcache may have already submitted a form or recorded a view. Refresh read-only information or reconcile local state with the server without repeating an action.
Clean Up for Freeze, Not for Destruction
When a hidden page is frozen, normal task queues may be suspended. Chrome's guidance calls out resources such as IndexedDB connections, BroadcastChannel connections, WebRTC connections, polling, WebSockets, and Web Locks. Work that would keep a page from entering bfcache should be paused or released before freeze where appropriate.
document.addEventListener('freeze', () => {
stopPolling();
channel?.close();
socket?.close();
});
document.addEventListener('resume', () => {
startPolling();
reopenChannelIfNeeded();
});
These callbacks are not a universal cleanup recipe. Closing a shared resource may have application-specific consequences. Treat them as a prompt to test the resources your page keeps alive, not as a pattern to paste into every route.
Audit Framework and Library Boundaries
The most stubborn listeners usually do not appear in page components. They live in a telemetry wrapper, an A/B testing tag, a payment flow, a support widget, or a client-side router integration.
Review Dependencies by Runtime Behavior
Read the deployed version's documentation and changelog. Search its distributed files when documentation is vague. If a vendor script registers unload, record the dependency, version, registration point, and business owner. You need that evidence before deciding whether to upgrade, configure, remove, or temporarily isolate it.
Avoid patching minified third-party code in production. A local patch may make the event disappear while creating an unmaintainable fork. Prefer a supported release or a documented configuration path.
Watch for Tag Manager Drift
Client tags can change without an application deploy. Include tag-manager previews, consent states, and major user flows in the audit. A runtime inspection in a test browser can find registrations that source searches miss.
Test the Migration in Browser Reality
Unit tests can assert that a handler runs. They cannot prove that the browser permits the lifecycle sequence your design expects. Use browser-level tests and a manual pass.
Test Three Scenarios
First, move from an active page to another route and confirm state or analytics reaches its intended durable destination. Second, use back and forward navigation and confirm the restored UI is correct without duplicate events or broken controls. Third, test backgrounding and returning on a mobile device or emulator.
Check the browser's bfcache diagnostics. Chrome's DevTools and the notRestoredReasons API can explain why a navigation could not use bfcache. Treat those reasons as diagnostics, not a stable API contract. Chrome warns that reason text can change.
const entry = performance.getEntriesByType('navigation')[0];
console.log(entry?.notRestoredReasons);
Measure Before and After
Record existing bfcache eligibility, analytics event volume, client error rate, and any business metric tied to final-page events. Then compare after the change. A migration that improves bfcache but silently removes a necessary draft save is not successful.
The same discipline protects against misleading wins. More bfcache restores can alter pageview behavior, so analytics teams should know when the lifecycle model changes.
Use an Opt-Out Only With an Exit Plan
If a critical flow breaks and an opt-out is the least risky immediate action, document it as temporary. Name the route or origin, the exact policy, who owns it, and the date it will be tested again.
The permanent fix is still to replace the last-chance logic with a lifecycle-aware design. Chrome's decision did not make unload unreliable. It made the failure mode visible.
Decide Whether the Work Needs Delivery, Durability, or Cleanup
Many bad migrations happen because an unload listener is treated as one category of work. It was usually doing one of three different jobs: attempting to deliver a small message, preserving data that the user expects to see later, or releasing a local resource. Those jobs have different guarantees.
Delivery is often best effort
Analytics, diagnostics, and small session events can be worth sending when a document becomes hidden. They must tolerate loss and duplication. A mobile browser may suspend a page before a request starts, a connection may disappear, or a restored page may produce another lifecycle transition.
Use a stable event identifier and let the receiving service deduplicate it. Keep payloads small. navigator.sendBeacon() is designed for this class of work, and fetch() with keepalive: true can be appropriate when you need request options that Beacon does not expose. Neither turns browser exit into a transaction boundary.
function reportPageHidden() {
const payload = JSON.stringify({
eventId: crypto.randomUUID(),
path: location.pathname,
occurredAt: Date.now(),
});
navigator.sendBeacon('/events/page-hidden', payload);
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') reportPageHidden();
});
The server should treat the event ID as the idempotency key. If your product needs a record with accounting, billing, or security consequences, create it before the page can disappear and wait for an explicit response while the user is still interacting.
Durable data needs an earlier save point
A draft, form edit, or configuration change is not analytics. Users reasonably expect it to survive navigation, backgrounding, a battery failure, or an operating-system memory decision. The design should save on a meaningful user action, periodically while editing, or when the document becomes hidden as a final best-effort check.
Keep a local recovery copy only when that fits the data and privacy model. For example, a non-sensitive editor may write a small draft to IndexedDB so the next page load can offer recovery. A healthcare, finance, or shared-device workflow may need a different policy. Do not add local persistence casually and then discover that stale or sensitive values remain on the device.
Cleanup should preserve restoration paths
Closing a resource at every navigation can be wrong when bfcache restores the page with its JavaScript state intact. Start with the resource's documented lifecycle and test it in the browser you support. Polling may pause and resume. A WebSocket may need a reconnection policy. A lock or database handle may need release before freeze to improve bfcache eligibility.
The replacement is not "close everything on pagehide." It is a small, reviewed lifecycle for each resource that can survive, pause, or be recreated safely.
Make the Migration Safe for Single-Page Applications
Single-page applications often hide lifecycle work in router hooks. Route changes, full-document navigations, tab backgrounding, and history restoration are different events. A hook that runs during an internal route transition cannot replace a browser-level listener for a user closing a tab, and a browser-level listener should not be used to duplicate every router event.
Keep page and route telemetry separate
Decide whether you are measuring a virtual route view, the document's visibility transition, or a completed business flow. Then use separate event names and deduplication rules. Combining all three into one "page exit" event makes reporting hard to interpret after the migration.
For a client-side router, ordinary route instrumentation may look like this:
router.on('routeChangeComplete', (url) => {
recordRouteView({ url });
});
That is useful for virtual navigation. It should not be assumed to run when the browser freezes or kills the document. Conversely, a visibilitychange listener should not stand in for route analytics because a user can switch tabs without changing routes.
Guard one-time setup in restored pages
A bfcache restore can expose setup code that assumed every return was a fresh load. If an application initializes timers, message listeners, or analytics on every pageshow, it can create duplicate work after a back navigation.
Keep initialization idempotent. Use a module-level guard, a component lifecycle that removes listeners on unmount, or a resource manager that knows whether it is resuming a paused service. The right pattern depends on the framework, but the test is simple: back and forward navigation should not multiply listeners or requests.
Review Privacy and Consent Before Moving Analytics
Changing an analytics event's lifecycle can change when it is collected. A listener that fires on visibilitychange may run more often than the old, unreliable unload callback. That can be an improvement in data quality, but it also requires the same consent and data-minimization review as any telemetry change.
Do not use lifecycle events to evade consent
The migration should respect the existing consent state. If analytics is disabled, the hidden-state listener should not quietly send a final beacon. If consent changes while a page is open, use the current policy when the event occurs rather than the state from initial page load.
Reconcile reporting changes with analysts
Teams may see an apparent rise in session-end events after moving off unload. That does not automatically mean more users reached the end of a flow. It may mean the browser now has a more reliable chance to report a small event, or that a restored page is being counted twice.
Annotate the dashboard and compare the old and new implementations during a controlled rollout. Retain a sample of event IDs and lifecycle reasons long enough to explain material shifts. This is more useful than arguing from aggregate charts after the old code has disappeared.
Avoid These Replacement Patterns
The goal is a design that works when a lifecycle event is missing, repeated, or delayed. Several common substitutions keep the original assumption alive under a new name.
Do not send large payloads at the last moment
Do not wait for visibilitychange to upload a file, serialize a large document, or finish a critical request. Browser keepalive budgets are limited and lifecycle scheduling is intentionally constrained. Save the work while the page is active, then use lifecycle events only for a small checkpoint or diagnostic.
Do not keep beforeunload attached permanently
beforeunload is for a user-visible warning about real unsaved changes. It should be attached only when there is something the user could lose and removed as soon as that state is resolved. Leaving it on a route permanently harms navigation behavior and makes the page's intent harder to reason about.
Do not infer completion from a client callback
A browser call that returns successfully tells you little about whether a server stored the data. For a critical action, show a completed state only after the application has the response it needs. For a best-effort event, record it as best effort and build reports that accept missing values.
Make Lifecycle Review Part of the Definition of Done
unload tends to survive because it is easy to add and hard to notice. A feature works in a desktop tab, the listener fires during a manual test, and the lifecycle assumption becomes part of the codebase. The fix is less about one migration than a small review habit.
Ask one lifecycle question during review
When a change sends data, stores a draft, opens a connection, starts polling, or registers a global listener, ask how it behaves when the document becomes hidden, is restored from history, or is terminated without another callback. The answer may be simple. A local preference can save immediately. A stream can reconnect. An upload can show explicit progress and resume later.
The question catches the cases where a "last chance" callback is carrying work that belongs earlier in the product flow. It also makes browser-specific behavior a design decision instead of a surprise reported from a phone after release.
Keep a small regression test around each repaired path
Once an unload dependency is removed, add the smallest browser-level check that proves the new behavior. It might assert that a dirty editor exposes a warning only while edits are unsaved, that a hidden-document event does not duplicate an analytics record, or that a history restore leaves a live control usable.
The test should describe the user outcome, not merely assert that a replacement event fired. Events change. The need to preserve a draft, avoid duplicated billing telemetry, or restore a usable page does not.
A Practical Migration Checklist
- Search source, built artifacts, and runtime tags for
unloadandbeforeunload. - Classify each listener by its actual business job.
- Move small state persistence and best-effort analytics to
visibilitychange. - Make lifecycle analytics idempotent.
- Add
beforeunloadonly during genuine unsaved changes. - Test back and forward navigation, hidden state, and mobile backgrounding.
- Inspect bfcache diagnostics and remove remaining blockers you own.
- Track any opt-out with an owner and expiry date.
Sources and Further Reading
- Chrome 155 unload announcement
- Chrome's unload deprecation guide
- Page Lifecycle API guide
- bfcache notRestoredReasons API
- Lighthouse bfcache guidance
Frequently Asked Questions
Can we keep unload for a legacy route?
Chrome provides opt-outs for limited cases, but keep them temporary. Give the route an owner and migrate the underlying work to an event that matches its real requirement.
Is visibilitychange guaranteed to send a network request?
No browser lifecycle callback guarantees delivery. Use it to start small best-effort work, use an endpoint designed for retries or duplicates, and save important data before the page is hidden.
What is the simplest first step?
Find every unload listener, write one sentence describing why it exists, and test that path with back navigation and mobile backgrounding. That usually reveals whether you need a draft save, analytics redesign, lifecycle cleanup, or a vendor upgrade.
FAQs
Does unload still fire in Chrome 155?
Chrome says unload handlers no longer fire by default in Chrome 155 and other Chromium-based browsers. Sites can use limited opt-outs, but Chrome recommends moving away from the event.
What should replace unload for analytics?
Use visibilitychange and treat the hidden state as the likely end of the session. Send small analytics payloads with navigator.sendBeacon or fetch with keepalive when suitable, and make collection idempotent.
Can beforeunload replace unload?
No. beforeunload is for warning about genuinely unsaved user changes. Add it only while unsaved work exists and remove it immediately after saving. It is not a reliable session-end callback.
Why does unload hurt bfcache?
An unload handler assumes a page is being destroyed. A browser may instead freeze it for back or forward navigation. The presence of unload listeners can prevent that fast restoration path.
How do we find unload usage?
Search source code and bundled output for addEventListener('unload'), onUnload, onunload, and framework wrappers. Then use Chrome's bfcache tools and navigation tests to find runtime registrations.
Does this change affect Chrome on iOS?
Chrome's announcement notes that unload may still fire on iOS because Chrome there uses WebKit rather than Chromium's Blink engine. Do not treat that as a reason to keep depending on unload.
Work with us
Let's build something together
We build fast, modern websites and applications using Next.js, React, WordPress, Rust, and more. If you have a project in mind or just want to talk through an idea, we'd love to hear from you.
Related Articles
Engineering • 16 min
Node.js 26.9: Web Workers, VFS, FFI, and node:bench
A focused guide to Node.js 26.9 changes: experimental Web Workers, mounted VFS modules, default FFI, node:bench, crypto updates, and DTLS.
9/18/2026
Engineering • 16 min
FastAPI Static Files on the CDN: The Deployment Architecture Behind Faster Python Frontends
Architectural guide to serving FastAPI static assets via a CDN with fingerprinting, caching, and deployment workflows.
9/16/2026
Engineering • 18 min
Cloudflare Workers Rebuilt a Module Registry for Node.js Compatibility. Resolution Was the Runtime Feature.
Cloudflare's new Workers module registry shows why specifiers, package metadata, caching, and lazy compilation decide whether Node.js compatibility holds up.
9/14/2026