The Back/Forward Cache (bfcache)
Your Page Isn't Reloading, It's Waking Up
Web developmentweb development / performance
Start with the analogy
When a user hits Back, the browser doesn't have to re-run your page. Modern browsers snapshot the whole thing — DOM, JS heap, scroll position, closures, your Redux store — and freeze it. Going Back releases a PAUSE button; it is not a reload.
That's why a bfcache hit feels instant (~0ms) while a real navigation is 800ms+. In Chrome, roughly 1 in 10 navigations is a history navigation, so this is a real chunk of your traffic.
Where the analogy breaks — nuance #1: frozen, not fresh
Your page resumes into a stale world. No DOMContentLoaded, no load, no module re-evaluation. Your analytics never fires a pageview. That "5 items in cart" badge is 20 minutes old. That JWT may have expired.
The resume hook is pageshow, not load:
window.addEventListener('pageshow', (e) => {
if (!e.persisted) return; // normal load — ignore
refreshCart(); // re-fetch volatile state
analytics.page(); // count the view
});
window.addEventListener('pagehide', (e) => {
if (e.persisted) pauseVideo(); // about to be frozen
});
Nuance #2: you don't opt in — you get disqualified
bfcache is on by default. Most sites lose it by accident. The top killers:
- Cache-Control: no-store on the HTML document → instant disqualification. This is the #1 cause, and it's usually copy-pasted "for security" onto pages that hold no secrets. Prefer no-cache, private — no-cache still revalidates, but keeps bfcache.
- An unload listener anywhere, including inside a third-party script. unload is fundamentally incompatible with freezing. Replace it with pagehide.
- beforeunload is tolerated in Chrome but has historically blocked bfcache in WebKit — keep it only on forms with real unsaved work.
- An open IndexedDB transaction, or an embedded iframe that itself sends no-store.
How to actually check (30 seconds)
Chrome DevTools → Application → Back/forward cache → "Test back/forward cache". It navigates away and back, then prints the exact blocking reason, e.g. UnloadHandlerExistsInMainFrame.
In production, read it from RUM:
const nav = performance.getEntriesByType('navigation')[0];
console.log(nav.notRestoredReasons);
The trap practitioners hit
You "fix" bfcache, then a logged-out user hits Back and sees the fully rendered dashboard from their previous session. That's not a bfcache bug — it's your app never re-validating auth on resume. Do the session check inside the e.persisted branch and hard-redirect from there.
Your exercise
Open your own app in Chrome, run the bfcache test, and write down the blocking reason it prints. Then check your headers:
curl -sI https://yoursite.com | grep -i cache-control
If an HTML document that shows no per-user secrets is sending no-store, that one header is costing you every single Back navigation.
Question to sit with: what in your app would silently be wrong if a user came back to it after 30 minutes frozen?