Press back, and the page you left reappears exactly as you left it. Scroll position, open dropdown, half-typed comment: all restored, all instantly. It feels like memory.
It is not memory. It is three unrelated systems, running different code, agreeing by coincidence more often than by design, and the moment your code depends on that coincidence, you get a bug that only reproduces on a phone, on the second visit, after scrolling past the fold.
Most of what goes wrong with the back button goes wrong because engineers reach for the browser's word for it, “cache,” and assume it means one thing. It means three, they are owned by different parties, and only one of them is yours.
The three layers
Stack them from the browser's memory down to your own code, and the ownership becomes obvious.
The confusion is almost always a category error between the bottom layer and the top one. Engineers hear “the browser caches the page” and assume their scroll position is part of that cache. It never was. The bfcache preserves the tab wholesale, exactly once, for exactly the case where nothing changed. Everything else, the case where the tab was evicted, where a script disqualified it, where the user closed and reopened the browser, falls straight through to layer three, and layer three is empty unless you filled it.
The three ways back can happen
Same button, same gesture, three completely different code paths underneath, and the DOM you get back is different in each one.
Only the first scenario is free. The other two are yours to design, and most codebases design for neither. They get the second one by accident, half work, and call the missing scroll position and the re-fetched data “a small bug we’ll fix later.”
Six steps. The first two are about not sabotaging the layer you do not control. The rest are about actually owning the layer you do.
Stop disqualifying yourself from bfcache
A single unload event listener anywhere on the page, yours, or a third-party chat widget's, or an analytics snippet's, disqualifies the entire tab from bfcache in Chrome and Firefox. Nobody decided this trade-off on purpose. It is usually a leftover from a beacon pattern that predates pagehide and navigator.sendBeacon, both of which do the same job without the cost.
Chrome's DevTools has a panel for exactly this question (Application → Back/forward cache), and it will name the blocking reason instead of making you guess. Toggle the usual suspects below and watch the verdict change.
Branch on persisted, not on habit
pageshow fires on every arrival at a page, including the very first one, and carries a boolean that tells you which of the three scenarios you are actually in. Most code ignores it and re-runs its full mount logic every time, which is harmless when bfcache was not involved and wasteful, or actively wrong, if it re-fetches something the frozen DOM already reflects correctly, when it was.
window.addEventListener('pageshow', (event) => { if (event.persisted) { // Restored from bfcache. The DOM, scroll position, and every // in-memory value are already exactly as they were. Do nothing. return } // A fresh mount: this is a normal load, not a resume. // Re-check anything time-sensitive (auth, feature flags), // and restore whatever layer 3 state you own yourself. restoreOwnState()})Own your scroll restoration deliberately
Browsers default history.scrollRestoration to “auto,” which tries to restore scroll on its own, and fights every client-side router, because the router mounts new content asynchronously and the browser has no idea when that content is actually ready to be scrolled into. Set it to “manual” once, and take the job yourself.
The common bug here is not forgetting to restore scroll. It is restoring it too early, before the data that position depends on has rendered, so the page scrolls confidently to a spot that does not exist yet, and settles at the top instead.
Key by history entry, not by URL
Two history entries can point at the exact same URL: open a list, scroll it, click into a detail, hit back, scroll it further. Both visits to /jobs?page=2 are real, and they do not share a scroll position. history.state gets a fresh, unique key per entry the moment you push it. Use that key in sessionStorage, never the pathname.
// Once, on the client, before your router takes over:if ('scrollRestoration' in history) { history.scrollRestoration = 'manual'} // Key everything by the current history entry, not the URL:// history.state.key is unique per entry even when two entries// share a URL.function keyFor(state) { return state && state.key ? state.key : 'initial'} window.addEventListener('popstate', (event) => { const saved = sessionStorage.getItem(`scroll:${keyFor(event.state)}`) if (saved != null) { // Wait for the content this position depends on to exist, // restoring before the data has rendered scrolls to nowhere. requestAnimationFrame(() => window.scrollTo(0, Number(saved))) }})Decide, per surface, what “back” even means
In a multi-step wizard, should back step back one field, or leave the flow entirely? After a destructive confirmation dialog, does back cancel the action silently, or does it need its own undo? These are not things the browser decides. The browser only fires an event; the meaning of “the user asked to go back” on any given screen is a product decision wearing a technical costume.
Test the failure, not just the happy path
Throttle the network and hit back mid-fetch. Let an auth session expire server-side, then hit back into a bfcache-preserved screen that still thinks it is logged in. Open the same URL in two tabs and confirm they do not share scroll state. None of this shows up in a quick manual click-through, and all of it ships anyway if nobody looked.
A few things about the back button that never show up in a straightforward reading of the spec.
- Third-party scripts can spend your bfcache eligibility without telling you. A chat widget or a tag manager snippet with a single
unloadlistener disqualifies your entire page. You will not find this bug by reading your own code. - A bfcache-restored screen does not know time passed. If a session expired server-side while the tab was frozen, the restored DOM still shows the authenticated view, correctly, because nothing about it was wrong when it was frozen, until the first action fails and reveals it.
- Never let a POST be the page “back” lands on. Re-submitting a form on back is the oldest version of this whole problem. Redirect after every mutation, so the history entry the browser can return to is always a GET.
- A client-side router cache is layer two, not layer one. Next.js keeps rendered route segments client-side so back navigation can skip a re-fetch, genuinely useful, and a different mechanism from the browser's bfcache, with a different lifetime and its own invalidation rules. Treating the two as one system is the exact category error this essay opened with.
- Mobile Safari discards tabs the desktop browser would keep. Memory pressure evicts bfcache entries more aggressively on phones, which means the “hard reload” scenario is not an edge case there. For a lot of real traffic, it is the common one.
A job-listings site. A filtered, paginated list; a detail page for each listing; agents click in and out of dozens of listings per session, constantly comparing.
Layer one. The list and detail pages have no unload listeners and no open sockets, so bfcache is available whenever the tab was not evicted for memory. On a same-tab back, nothing below this paragraph runs at all.
Layer two. Filters and pagination live in the URL, so a shared link reproduces the exact list a colleague is looking at. The framework's route cache holds the last-fetched page of results, so a soft back does not always re-fetch, but it always re-mounts, so nothing below this paragraph is free.
Layer three. Scroll position and “was the salary filter panel expanded” are saved to sessionStorage, keyed by history.state.key, and restored only in the pageshow branch where persisted is false, because in the branch where it is true, layer one already got it right, and re-applying a saved value on top would be the bug, not the fix.
The verdict
The back button is not a bug you patch after the fact. It is a contract between three systems: one the browser owns completely, one it shares with your framework, and one that was always yours, whether or not you noticed you were holding it.
The browser is doing you two favors and expecting a third in return. Layer three is the toll.
Pay it on purpose: key by entry, restore on the right branch, stop handing your bfcache eligibility to a script you did not audit, and the back button stops being a source of bug reports and starts being the fast path it was always advertised as.
New essays. New tracks. One email a month, max.
Reply to anything I send and it goes straight to me.
System Design Is Not a Crime
Frontend is a distributed system with an untrusted, unobservable replica on hardware you did not choose.