← blog
JavaScriptOctober 5, 2026 · 6 min read

beforeunload Can Still Kill Your bfcache. Mount It Only When Dirty.

A `beforeunload` listener you left mounted forever can still quietly cost you bfcache eligibility — and an `unload` listener reliably will. Here's the pagehide/pageshow fix, and why 'mount it only when there's something to lose' is the durable rule.

Parsa Jiravand · Frontend engineer · building bestpractic
`beforeunload` Can Still Kill Your bfcache. Mount It Only When Dirty.

Hit the back button on most sites and the previous page just reappears — no spinner, no white flash, no network tab lighting up. It was never gone. The browser froze it in memory instead of throwing it away.

Then one day, on your own app, it stops being instant. Same page, same back button, now with a full reload every time. Nobody touched the router. Somebody added a "you have unsaved changes" warning.

That instant restore has a name: the back/forward cache, or bfcache. When you navigate away, instead of destroying the page, the browser can suspend it whole — DOM, JavaScript heap, running timers, scroll position, all of it — and hand it straight back if you hit back or forward. No HTML re-parse, no JS re-execution, no re-fetch. It just un-pauses.

Safari and Firefox have shipped some form of this for a very long time. Chrome was the holdout — for years it only cached in narrow cases — until it rolled out broad bfcache support starting with Chrome 96 in late 2021. Today, on a site that qualifies, back and forward navigation across all three engines can be effectively instant.

"On a site that qualifies" is doing a lot of work in that sentence. Plenty of real apps don't, and most of the time nobody notices, because nothing announces the miss. The page just... loads normally, the way it always has. You only notice the regression if you had bfcache and lost it.

Say your app has a form, and you don't want people losing work by fat-fingering the back button. The standard move:

JavaScript
1
2
3
4
5
6
window.addEventListener('beforeunload', (e) => { if (hasUnsavedChanges) { e.preventDefault(); e.returnValue = ''; } });

Reasonable. It only shows the native "leave site?" prompt when hasUnsavedChanges is true. Surely an idle listener that does nothing most of the time can't cost anything?

For a long time, in the browsers that enforced this most strictly, it could. The check happened at navigation time, before your callback ever ran — so the browser couldn't know whether this departure was one where the handler would actually intervene. It only knew a beforeunload handler existed, and an always-mounted one is a standing promise the page might need to act on the way out, which a suspended, un-runnable page can't keep. So the listener being registered at all — not what it did on any given visit — was what counted against you.

That specific rule has been shifting. Some engines have relaxed how much a mere beforeunload registration costs you; others still treat it as a real risk. Which means you can't safely assume either behavior for the whole spread of browsers and versions your users are actually running — and the fix costs nothing regardless of which way today's browser leans.

unload doesn't get the benefit of that ambiguity: it's the one still widely documented as a reliable disqualifier, in some engines for the page's whole lifetime, listener present or not, browser-relaxation or not.

Open Chrome DevTools → Application → Back/forward cache, and click "Test back/forward cache." It runs the browser's own eligibility check and names the specific not-restored reasons for your page, on your browser version — which beats guessing from an article, this one included.

The warning is legitimate — you don't want to silently lose someone's draft. What's fixable is when the listener exists, not whether it does.

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
function updateUnsavedGuard(hasUnsavedChanges) { if (hasUnsavedChanges) { window.addEventListener('beforeunload', warnOnLeave); } else { window.removeEventListener('beforeunload', warnOnLeave); } } function warnOnLeave(e) { e.preventDefault(); e.returnValue = ''; }

Attach it only while there's something to lose, and detach it the moment the draft is saved. A page with no unsaved changes has no listener mounted, so it's eligible for bfcache the rest of the time — which, for most forms, is nearly always.

Runs right in your browser — poke at it and watch the concept react live.

For cleanup that isn't about a warning dialog — closing a WebSocket, cancelling a poll, flushing analytics — reach for pagehide instead of unload. unload is the one without the ambiguity: some engines disqualify a page from bfcache just for having an unload listener anywhere on the page, for its whole lifetime, warning or not.

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
window.addEventListener('pagehide', (event) => { socket.close(); clearInterval(pollTimer); // event.persisted === true means the page is being frozen for bfcache, // not destroyed — the same handler covers both cases safely. }); window.addEventListener('pageshow', (event) => { if (event.persisted) { // restored from bfcache, not a fresh load — the socket you closed // in pagehide is gone, so reopen it and refresh anything time-sensitive. socket = reconnect(); refreshStaleTimestamps(); } });

pageshow fires on every page load, fresh or restored — event.persisted is the flag that tells you which one just happened. That's the piece most cleanup code is missing: it assumes a fresh mount and re-runs setup that a restored page already has, or it never notices the page came back at all and sits there with a closed socket and a timestamp from ten minutes ago.

Fix both, and the back button goes back to being boring: instant, no flash, no waterfall in the network tab. The unsaved-changes prompt still fires when it should — it's just not paying rent on every page for the whole time your form is untouched. And a restored tab reconnects its socket and refreshes its stale data instead of quietly pretending nothing happened while it was away.

One more worth a mention, since it's not a listener at all: a Cache-Control: no-store header used to disqualify a page from bfcache outright, no JavaScript involved. Chrome has since relaxed that — a no-store page can now enter bfcache as long as the user's cookies and Authorization state don't change while it's parked (either one evicts it immediately, and it gets a shorter hold than a normal bfcache entry regardless), though a no-store page that also keeps a WebSocket, WebTransport, or WebRTC connection open still gets excluded outright. Outside the no-store case, Chrome has separately stopped treating an open WebSocket as an automatic blocker on its own — since Chrome 149, it closes the socket on the way out and hands you an onclose/pageshow to reconnect from, instead of refusing to cache the page at all. WebTransport and WebRTC didn't get the same carve-out; they still block bfcache outright, with or without no-store. All of this is Chrome-specific and recent, so don't assume it holds in every engine or for every connection type — if your listeners are clean and bfcache still won't take, check this header (and what's still open) and verify the current behavior in DevTools rather than trusting any rule, old or new, by default.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Open DevTools → Application → Back/forward cache on your own site and click "Test back/forward cache." What comes back — and were you expecting it?


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.