HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 892 min read

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?