Engineering Diaries · Browser Internals
§
Browser InternalsPerformancePage Lifecycle

Chrome Stopped Running Your unload Handler. If Your Analytics Still Use It, Your Numbers Already Changed.

Since Chrome 154, unload handlers are off by default on every Chrome page load - they register without error and never run. Reproduced in Chromium: what it does to the Back button, which data quietly went missing during the seven-month rollout, and the pagehide pattern to use instead.

01 · The Handler Nobody Deleted

The Code Is Still There. Chrome Just Stopped Calling It.

Somewhere in most frontend codebases older than a couple of years, there is a block that looks like this:

analytics.js
addEventListener("unload", () => {
  navigator.sendBeacon("/collect", buildSessionEndPayload());
});

It sends a "session ended" event, or time on page, or releases a lock, or tells the server a draft is no longer being edited. Someone wrote it years ago, it worked, and nobody has opened that file since.

On 22 September 2026, Chrome 154 finished a rollout that has been running since March: unload handlers are now disabled by default on 100% of Chrome page loads. The listener still registers. addEventListener does not throw. Nothing shows up in the console. The function just never runs.

I wanted to see that for myself rather than trust a changelog, so I built a small test bed - a Node server with one page per variant, each page firing a beacon back to the server from a different lifecycle event - and drove Chromium through it with Playwright. Load the page, click a link, press Back. Then count what reached the server.

With the policy Chrome now applies by default, the unload beacon never arrived. Not late. Not sometimes. Never. And the same page came back from the back/forward cache instantly, which is the entire reason Chrome made the change.

A deleted feature breaks loudly. A disabled event handler breaks your dashboard, quietly, one percentage point at a time.

This piece covers what Chrome actually changed, what the test showed, what kind of data you have probably already lost, how to find the unload handlers you did not write yourself, and what to replace them with. It is also a follow-up to my Cache-Control piece, where the Back button kept showing up as the odd one out. This is the other half of why.

02 · The Timeline

This Did Not Happen Overnight. That Is The Problem.

Chrome did not flip a switch. It turned a dial, over about seven months, and the way it turned that dial is what makes the breakage hard to notice.

The mechanism is a Permissions Policy feature called unload. When the policy denies it, unload listeners on that document do not fire. For years the default was "allow". The deprecation is Chrome moving that default to "deny" for a growing share of page loads. The top 50 sites were moved first, and from Chrome 146 onwards the rest of the web followed:

ChromeStable datePage loads with unload off by default
14610 Mar 20261%
1477 Apr 20265%
1485 May 202610%
1492 Jun 202620%
15030 Jun 202640%
15128 Jul 202660%
15225 Aug 202680%
15422 Sep 2026100%

The detail that matters most is how a page load gets picked. According to Chrome's deprecation explainer, the bucket comes from a combined hash of the user and the site. That was a deliberate choice: random selection would make bugs impossible to reproduce, picking by user would break everything for some people, and picking by site would break some sites completely overnight.

That reasoning is fair. But look at what it does to your metrics. For any one user on your site the behaviour is consistent, so nobody files a bug saying "it works sometimes". Across all your users, though, the share whose unload handler stopped firing went up by a few points every four weeks, for seven months.

On a dashboard, that does not look like an incident. It looks like a slow trend. "Session-end events are down 4% this month" is the kind of line that gets blamed on seasonality, a marketing change, or a tracking blocker, and then forgotten.

Where we are now

As of Chrome 154, the default is "deny" everywhere. If your users are on current Chrome and you have not opted out, assume your main-frame unload handlers are not running. Edge and other Chromium-based browsers ship on their own schedules, so check them separately rather than assuming they match.

03 · The Test

Eight Pages, One Back Button, And A Server Counting Beacons

Each test page did the same thing: register a pageshow listener to record whether the page came out of the back/forward cache, register one extra thing depending on the variant, and link to a second page. The script loaded the page, clicked through, pressed Back, and then read two things: event.persisted from pageshow, and whatever beacons the server had received.

The browser was Playwright's bundled Chromium 147. Version 147 was only at the 5% stage of the rollout, which turned out to be useful - by default, my local origin was still in the "allow" bucket, so I could compare the old behaviour against the new one by setting the policy header myself.

Page variantRestored from bfcache?Beacon received
Plain pageYes-
unload listener, no policyNounload fired
unload listener + Permissions-Policy: unload=()Yesnothing
unload listener + Permissions-Policy: unload=(self)Nounload fired
pagehide listenerYespagehide fired, persisted=true
beforeunload listener (empty)Yes-
Cache-Control: no-storeYes-
Open BroadcastChannelYes-

Rows two and three are the whole story. The unload=() row is exactly what Chrome 154 now does by default. The handler was registered, the page navigated away, and the handler did not run. In exchange, the Back button restored the page from memory instead of reloading it.

Row four is the opt-out, which I come back to in section 08. It brings the old behaviour back - the handler fires - and the cost comes back with it. The page reloads in full every time someone presses Back.

For the unload rows, I also read performance.getEntriesByType("navigation")[0].notRestoredReasons after going back. Chrome reported the reason in plain text:

notRestoredReasons, unload variant
[{ reason: "masked" }, { reason: "unload-listener" }]

The last three rows surprised me a little, in a good way. An empty beforeunload listener did not block the cache in this test. A no-store page was restored too - Chrome has allowed that since its 2025 rollout, though it evicts those pages when cookies change and keeps them for a shorter time. And an open BroadcastChannel did not block it either. Older bfcache advice lists all three as blockers. The browsers have moved on, and a lot of blog posts have not.

Scope of the test

One Chromium build, desktop, headless, a local origin, a simple link-then-Back flow. It shows the mechanism clearly. It does not prove every rule for every browser - Firefox and Safari make different choices, covered in section 09.

04 · Why Chrome Did It

The Back Button Was Paying For A Handler That Barely Worked

The back/forward cache (bfcache) keeps a page you just left frozen in memory - DOM, JavaScript heap, scroll position, all of it - so that pressing Back shows it instantly instead of fetching, parsing and running everything again. It is one of the biggest free performance wins on the web, and it applies to a lot of traffic:

1 in 10
Desktop navigations that are back or forward
1 in 5
Mobile navigations that are back or forward
30%+
Cacheable navigations that had an unload handler
100%
Chrome page loads with unload off, from M154

The first two numbers are from the web.dev bfcache guide. The third is from Chrome's deprecation explainer: unload handlers showed up in over 30% of navigations that could otherwise have been cached. That was the problem.

Here is the conflict. The HTML spec says unload only fires when the page is actually being destroyed - if the browser is keeping the page alive in bfcache, unload does not run. Safari has always worked that way. But desktop Chrome and desktop Firefox went the other way: if a page had an unload handler, they skipped the bfcache so the handler would definitely run.

So on desktop, one unload listener anywhere on the page - including inside an iframe from an ad network or a chat widget - meant every Back press to that page was a full reload.

And what did you get for that? A handler that was much less reliable than people assumed. The explainer quotes Nic Jansma's beaconing research: unload handlers ran 95%+ of the time on desktop Chrome, Edge and Firefox, but only 57% to 68% on mobile browsers and desktop Safari. Mobile operating systems kill background tabs without warning, and no event fires when that happens.

Every desktop user paid for unload on every Back press, for an event that mobile browsers and Safari were already skipping a third of the time.

Chrome's team tried the polite route first - an opt-in policy so sites could disable unload themselves. According to the explainer, other browser vendors did not support that version. The flipped default is what they shipped instead: unload off unless you ask for it.

05 · What You Lost

The Missing Data Is Not Random. That Is What Makes It Dangerous.

If a handler fails at random, your numbers get noisier but stay honest. This one did not fail at random, and that is the part I would worry about first.

The explainer makes this point directly. Even before the deprecation, whether unload ran depended on whether the page was bfcache-eligible, which in turn depended on things like which third-party iframes loaded. So which data went missing depended on what the user did and which ads they happened to see. You cannot correct for a gap like that with a multiplier.

Now Chrome has added a second filter: during the rollout, whether your handler ran also depended on the user-and-site hash and on which Chrome version the user had. Over March to September, the share of Chrome sessions sending your unload event kept falling. Other browsers did not change.

So here is what I would expect to see, if your unload handler fed a metric:

  • Session-end or "page exit" events falling from March, with Chrome's share of those events falling faster than Firefox's or Safari's.
  • Time-on-page or engaged-time metrics shifting, if the final duration was only sent on unload. You are now left with whatever the last heartbeat recorded.
  • Abandonment funnels looking healthier than they are. If "user left the checkout page" was an unload beacon, those exits simply stopped being recorded.
  • Server-side resources not being released. Edit locks, presence indicators ("Divyansh is viewing this document"), reserved seats, temporary uploads - anything cleaned up by an unload request now waits for a timeout instead.
  • Iframes that told their parent "I navigated away" through unload going quiet. The deprecation covers subframe navigations too.

The fastest check: split your exit-event counts by browser, by week, from February onwards. If Chrome's line steps down roughly every four weeks from March, in line with the stable releases in the table above, while Firefox stays flat, you have found it.

The uncomfortable partIf a product decision was made in the last six months based on a trend in exit or engagement data, it is worth checking whether that trend was user behaviour or Chrome's rollout schedule.
06 · Finding The Handlers

Most Of The Unload Handlers On Your Page Are Not Yours

Searching your repo for "unload" is the obvious first step, and you should do it. It will also miss most of them. Analytics SDKs, A/B testing tools, session replay scripts, consent banners, chat widgets and older polyfills have all registered unload handlers at some point. They arrive through a script tag or a dependency, not your source tree.

A few checks that find what is actually registered at runtime:

  • DevTools console: getEventListeners(window).unload lists every unload listener on the window, and you can click through to the source. It is a DevTools console utility, so it will not work in your app code.
  • DevTools Application panel, Back/forward cache section: "Test back/forward cache" navigates away and back, then lists every reason the page was not restored, including which frame caused it.
  • In production, from real users: read notRestoredReasons on the navigation entry after a back/forward navigation and send it to your monitoring. That shows what blocks the cache for real sessions, not just your own clean dev profile.
  • In your built bundle, not just source: grep the output for "unload" and onunload. Dependencies show up there even if they do not appear in your code.
report-bfcache.js
addEventListener("pageshow", (event) => {
  // Restored from bfcache: the page never reloaded, so this is the only signal.
  if (event.persisted) return report("bfcache", { restored: true });

  const nav = performance.getEntriesByType("navigation")[0];
  if (nav?.type !== "back_forward") return;

  // A Back/Forward that reloaded instead. Chromium explains why.
  report("bfcache", { restored: false, reasons: nav.notRestoredReasons ?? null });
});

The order in that snippet matters. When a page is restored from bfcache, the navigation entry is still the one from the original load, so its type is not "back_forward". Check persisted first, or every successful restore is silently dropped from your numbers and your hit rate looks like zero.

For third-party scripts you cannot change, the next two sections cover the options: check whether a newer version of the vendor's SDK has already moved off unload, or decide whether you actually want their handler to run.

07 · The Replacement

pagehide And visibilitychange, With sendBeacon Doing The Sending

There is no single event that replaces unload, because unload was never answering one question. It was being used for two:

  • "The user might be gone for good - save what you have." Use visibilitychange when document.visibilityState becomes "hidden". It fires on tab switches, app switches, minimising and, on mobile, before the OS may kill the tab. Chrome's documentation calls hidden "the last reliable time to save app and user data". Treat it as a checkpoint, not a goodbye - the user may come back.
  • "The user is navigating away from this page." Use pagehide. It fires everywhere unload used to fire, plus when the page enters bfcache, and it does not block the cache. Its persisted flag tells you whether the page is being kept alive.
session-end.js
let flushed = false;

function flush(reason) {
  if (flushed) return;      // both events can fire for one exit
  flushed = true;
  navigator.sendBeacon("/collect", JSON.stringify(buildPayload(reason)));
}

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

addEventListener("pagehide", () => flush("pagehide"));

// The page can come back from bfcache, or the tab can become visible again.
// Either way, this is a new stretch of the session.
addEventListener("pageshow", (e) => { if (e.persisted) flushed = false; });
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") flushed = false;
});

Three things about this pattern that I would not skip:

  • Your data model changes. "One session-end event per page view" becomes "one or more hidden events per page view". A user who switches tabs five times sends five. Your backend has to treat them as checkpoints and keep the latest, not count them as exits.
  • Send with sendBeacon or fetch(url, { keepalive: true }). A normal fetch started while the page is being hidden can be cancelled. Both keepalive options have a small payload budget - the Fetch spec caps in-flight keepalive bodies at 64 KiB in total - so send a summary, not a replay buffer.
  • Do not do synchronous work in these handlers. The old trick of a synchronous XHR or a busy loop in unload to "make sure the request goes out" is exactly what browsers have spent years shutting down. Queue the beacon and return.
08 · The Escape Hatch

You Can Turn Unload Back On. Think Hard Before You Do.

Chrome kept an opt-out, because some sites cannot migrate quickly. It is the same Permissions Policy, set the other way:

response headers
# Re-enable unload handlers for this document (opt out of the deprecation)
Permissions-Policy: unload=(self)

# Explicitly disable them - what Chrome 154 now does by default anyway
Permissions-Policy: unload=()

In my test, unload=(self) did exactly what it says. The handler fired again, and the page went straight back to being excluded from bfcache. That is the trade, and there is no version where you get both.

For managed devices, there is also an enterprise policy, ForcePermissionPolicyUnloadDefaultEnabled, that keeps unload enabled by default. If you build internal tools that run on company laptops, it is worth knowing about, because your IT team may already have set it - which would mean your internal dashboards behave differently from the public site.

My view: use the opt-out as a short-term timer, not a fix. If a critical flow breaks today and the replacement needs a sprint, set unload=(self) on the routes that need it, ship it, and put a removal date on the ticket. Do not set it site-wide. Every page that carries it gives up instant Back navigation for every desktop Chrome user.

And the reverse is worth doing deliberately: send Permissions-Policy: unload=() yourself. Chrome already defaults to it, but setting it explicitly stops anyone from re-enabling unload by accident later, documents the decision in your config, and makes the behaviour clear to anyone reading your headers.

Iframes

Permissions Policy flows down from the top-level document. If you embed a cross-origin iframe that depends on unload, the parent page's policy decides whether it can have it. If you are the vendor inside someone else's iframe, assume you do not get unload, and use pagehide.

09 · Other Browsers

Chrome Is Done. Firefox Is Not. Write Code That Does Not Care.

This is Chrome's change. The other engines still do what they did before:

  • Firefox, desktop and Android: per web.dev's bfcache guide, a page with an unload listener is still not eligible for bfcache. The handler runs; the Back button reloads. That is the same trade Chrome just walked away from.
  • Safari: follows the spec. It tries to cache the page and does not run unload when it does. So an unload handler on Safari already ran only some of the time, which matches the 57% to 68% reliability figure.
  • Chrome on Android: already preferred bfcache over unload on mobile, so the deprecation mostly changes desktop.

So unload now does three different things depending on the browser: never runs (Chrome), sometimes runs (Safari, mobile), always runs and costs you the cache (desktop Firefox). The only code that behaves the same everywhere is the pagehide plus visibilitychange pattern from section 07. That alone is reason enough to migrate, Chrome deprecation or not.

What about beforeunload?

beforeunload is a separate event and it was not part of this deprecation. It still powers the "Leave site? Changes you made may not be saved" prompt. In my Chromium test, an empty beforeunload listener did not stop the page from being cached. The guidance from web.dev still applies, though, because browsers have not always treated it this kindly: add the listener only when there are unsaved changes, and remove it once they are saved.

unsaved-changes.js
const warn = (e) => { e.preventDefault(); };

export function markDirty()  { addEventListener("beforeunload", warn); }
export function markSaved()  { removeEventListener("beforeunload", warn); }
10 · Your Test Suite Cannot See Any Of This

Playwright Turns The Back/Forward Cache Off. By Default.

Before writing the test bed, I checked how Playwright launches Chromium, because I half-remembered something about it. In Playwright 1.59, the default switches include this line:

playwright-core/lib/server/chromium/chromiumSwitches.js
"--disable-back-forward-cache",

Which means if your end-to-end suite runs on Playwright's Chromium with default settings, no test you have ever written has exercised a bfcache restore. Every page.goBack() was a full reload. Bugs that only happen when a page comes back from memory - a stale auth state, a timer that was paused, a WebSocket that needs reconnecting, a "session started" event that never fires again - are invisible to that suite.

To get a real browser's Back behaviour in a test, remove the flag:

bfcache.spec.ts
const browser = await chromium.launch({
  ignoreDefaultArgs: ["--disable-back-forward-cache"],
});
// ...navigate away, then:
await page.goBack({ waitUntil: "commit" });
const restored = await page.evaluate(() => window.__lastPageshowPersisted);

Note the waitUntil: "commit". That was the second thing that tripped me up. A page restored from bfcache does not fire load again, because it never unloaded. So the default goBack(), which waits for load, hangs until it times out - on exactly the pages that worked best. My first run died on the very first page with a 30-second timeout. The test "failed" because the cache was doing its job.

Playwright's Firefox build did not restore anything from bfcache either, not even the plain page, which is why I have not put Firefox numbers in the table. If you need those, test in a real Firefox profile.

If your tests have never seen a bfcache restore, then your users are the ones testing it.

I would add one test like this per important page type, with an assertion on persisted. It catches the next third-party script that quietly adds an unload listener, which is the regression you are most likely to see.

11 · The Short Version

Delete Unload Before It Lies To You Again

  • Since Chrome 154 (22 September 2026), unload handlers are off by default on 100% of Chrome page loads. They register without error and never run.
  • The rollout went from 1% to 100% between March and September, bucketed by user and site, so any metric fed by unload has been drifting for months rather than breaking on one day.
  • Check exit, session-end and engaged-time metrics split by browser and by week. A Chrome-only decline in steps is the signature.
  • Find handlers at runtime with getEventListeners(window).unload, the DevTools bfcache tester and notRestoredReasons from real users - not just a grep of your own source.
  • Replace them with visibilitychange (hidden) as a checkpoint plus pagehide for navigation, sending with sendBeacon or keepalive, and dedupe on the server.
  • Permissions-Policy: unload=(self) brings the old behaviour back, including the full reload on Back. Use it per route, with an end date.
  • Remove --disable-back-forward-cache in at least one Playwright project, or your suite will never see what your users see when they press Back.

Unload was always a strange event. It promised a last word that the browser could not guarantee, and the price was the fastest navigation the web has. Chrome has made that call for you now. The only thing left to decide is whether you find out from your code or from a dashboard that has been slowly going wrong since March.

The page is not being destroyed any more. It is being put on a shelf. Write code for the shelf.

Test results were reproduced locally with a small Node 26 HTTP server driven by Playwright 1.59.1 against Chromium 147.0.7727.15 (headless, --disable-back-forward-cache removed). Each variant was loaded, navigated away from via a link and restored with the Back button; restoration was read from pageshow's persisted flag, and beacons were counted on the server. The rollout schedule, the user-and-site bucketing and the enterprise policy come from Chrome's "Deprecating the unload event" documentation and the Permissions-Policy unload explainer, which also quotes the 95%+ and 57% to 68% reliability figures from Nic Jansma's beaconing research and the 30%+ prevalence figure. Back/forward navigation share and per-browser unload behaviour come from web.dev's back/forward cache guide; the no-store behaviour from Chrome's bfcache-for-Cache-Control: no-store documentation. Firefox and Safari results were not reproduced locally (Playwright's Firefox also disables bfcache) - check them in a real browser before relying on exact behaviour.