A team adds a `window` `'unload'` listener for analytics, and their HTML document is served with `Cache-Control: no-store`. Why does either one on its own stop the browser from putting that page in the back/forward cache, and what else in a page commonly disqualifies it?
answer
- eligibility is binary, page-wide
- a promise the frozen page cannot keep
- one directive forbids retaining the document
- destroy-time handlers versus a frozen document
- in-flight work at navigation time blocks it
basics
~20 sBoth are promises the browser cannot keep while a page is frozen. An unload listener claims code runs at teardown, which a cached page never reaches, and Cache-Control: no-store says the response must not be retained at all — so the browser skips caching and does a full reload instead.
solid answer
~50 sThe back/forward cache only works if the browser can freeze a document and bring it back unchanged, so anything that contradicts that is a disqualifier. An `unload` listener is one: the whole point of bfcache is that the document is *not* unloaded, so a page that registers `unload` is refused eligibility in Chrome and Firefox rather than having its handler silently skipped — use `pagehide` instead. `Cache-Control: no-store` on the **document** response is another: it tells the browser not to retain this content, and browsers honour that by declining to keep the page alive in memory. Beyond those two, the commonly cited blockers are in-flight work and open connections at the moment of navigation — an unfinished IndexedDB transaction, in-flight `fetch`/XHR, an open WebSocket — plus anything in a cross-origin iframe that is itself ineligible. Note that `beforeunload` alone does **not** block bfcache in current Chrome or Firefox (2026).
code
javascript · 9 lines// Disqualifies the page from the back/forward cache in Chrome and Firefox:
window.addEventListener('unload', () => {
navigator.sendBeacon('/analytics', JSON.stringify({ left: Date.now() }));
});
// Keeps the page eligible and still runs when the user leaves:
window.addEventListener('pagehide', () => {
navigator.sendBeacon('/analytics', JSON.stringify({ left: Date.now() }));
});go deeper
Remember the single most-cited rule: do not use unload — use pagehide — because an unload listener costs the page its back/forward cache eligibility.
Explain why each disqualifier exists: unload promises destroy-time code a frozen page never reaches, and no-store forbids retaining the document. Be able to contrast no-store with no-cache, which does not block caching.
Demonstrate the audit: grep bundles and vendor tags for unload, scope no-store to genuinely sensitive responses, close sockets and in-flight work in pagehide, and check cross-origin frames, since eligibility is decided for the whole page.
Own it as a policy question — who is allowed to add an unload listener or a blanket no-store, how eligibility regressions are caught before release, and what you accept losing on pages where no-store is genuinely non-negotiable.
## The underlying rule Eligibility is not an arbitrary list. The back/forward cache works only if the browser can (a) stop running a document, (b) keep it in memory unchanged for a while, and (c) resume it as if no time had passed. Every disqualifier is something that makes one of those three impossible or unsafe. Learning the rule beats memorising the list, because the list moves between browser releases. ## The `unload` listener `unload` means "run this as the document is destroyed". A document that goes into the back/forward cache is deliberately **not** destroyed — it is frozen — so the event would either never fire, or fire much later when the entry is finally evicted, long after the user thinks they left. Rather than break the handler's contract, Chrome and Firefox refuse to cache pages that registered an `unload` listener at all. The listener does not have to be yours: one third-party analytics or ad script adding `unload` costs the whole page its eligibility, which is why "grep the bundle for `unload`" is a real first step. The replacement is `pagehide`, which fires on both the freeze and the destroy path and does not affect eligibility. ```js // costs the page its bfcache eligibility window.addEventListener('unload', sendAnalytics); // eligible, and still fires when the user leaves window.addEventListener('pagehide', sendAnalytics); ``` A related trap: `beforeunload` is *not* in the same category. In current Chrome and Firefox (2026) a `beforeunload` listener does not disqualify the page — it is the confirm-before-leaving hook, and browsers kept it compatible with bfcache. Candidates who lump the two together are repeating folklore. ## `Cache-Control: no-store` `no-store` on the **document's own response** is a directive not to retain that content anywhere. Browsers apply it to the in-memory frozen document too: a page served `no-store` is not put in the back/forward cache, and Back triggers a full navigation instead. This is deliberate rather than incidental — `no-store` is what applications put on authenticated pages precisely so a stale copy cannot reappear after the user leaves or logs out. The distinction people miss is that `no-cache` is a different directive: it means "you may store this, but revalidate before reuse", and it does **not** block bfcache. So a blanket "we send no-store on everything for safety" policy quietly turns off the back/forward cache site-wide, while `no-cache` would not have. Also note this is about the document response, not sub-resources. `no-store` on a JSON API response has no bearing on whether the page that fetched it can be frozen. ## In-flight work and open connections The other recurring family is state that cannot survive a freeze cleanly. Commonly cited cases at the moment of navigation are: - an **IndexedDB transaction still in flight** — a frozen page cannot commit or abort it, and other tabs would be blocked waiting, - **in-flight `fetch` or `XMLHttpRequest`** — a response would arrive at a document that cannot process it, - an **open WebSocket** connection — a live bidirectional channel whose peer expects an active client. Browsers differ here and have been actively changing behaviour: some now close such a connection on entry rather than refusing to cache the page. So state the shape of the rule — "open or in-flight connections at navigation time are a risk area" — rather than asserting one browser's exact current handling. The defensive pattern is the same either way: close sockets and stop in-flight work in `pagehide`, and re-open them in `pageshow` when `event.persisted` is true. ## Frames and the whole-page rule Eligibility is decided for the **page**, not per frame. If a cross-origin iframe embedded in your page is itself ineligible — say the vendor's frame registers `unload` — the top-level page is not restored either. That is a common reason a page that looks clean in your own code still never gets cached. ## What this means in practice The fixes are usually small and unglamorous: replace `unload` with `pagehide` everywhere including vendor scripts, narrow `no-store` to the responses that genuinely need it instead of applying it globally, tear down sockets and long-running work in `pagehide`, and audit third-party frames. None of that is clever, but eligibility is binary — one violation anywhere in the page and every Back navigation pays a full load.
- Does a `beforeunload` listener block the back/forward cache the way `unload` does?No. In current Chrome and Firefox (2026) `beforeunload` is compatible with the back/forward cache — it is the confirm-before-leaving hook and browsers kept it eligible. Only `unload` carries the destroy-time contract that a frozen page can never honour. Treating the two as equivalent is a common piece of outdated folklore.
- If `Cache-Control: no-store` is required on an authenticated page, is the back/forward cache simply lost there?For that document, yes — and often that is the point, since `no-store` exists so a stale authenticated view cannot reappear on Back. The mitigation is scope: apply it only to responses that genuinely carry sensitive content, rather than as a blanket header that costs eligibility on every public page too.
- How would a third-party script cost you eligibility without any change in your own code?Eligibility is judged for the whole page, including cross-origin iframes. A vendor tag that registers an `unload` listener, or an embedded frame that is itself ineligible, disqualifies the top-level page. That is why an audit starts with grepping bundles for `unload` and checking what embedded frames do, not only your own source.
- Why would an in-flight IndexedDB transaction be treated as a problem at navigation time?A frozen document cannot run code, so it can neither commit nor abort the transaction, and IndexedDB's locking means other tabs or workers could be left waiting on it. Rather than leave a database in that state, the browser treats an unfinished transaction as a reason not to freeze the page.
saying these in an interview costs you the question
- Claims beforeunload blocks bfcache the same way unload does
- Confuses Cache-Control: no-cache with no-store for eligibility
- Thinks an unload handler just gets skipped instead of disqualifying
- Applies no-store globally and calls it a safe default
- Assumes only the top-level document's own code affects eligibility