Medium wave · 540–1600 kHz77 stn
1446kHz

The Back Button Is Your Fastest Page Load

A towering brass clockwork archive where a glowing page, suspended mid-air in a glass bell jar, is being swung back into place on a copper rail the instant a great lever is pulled in reverse, neon teal energy trailing behind it, while a heavy iron padlock bolted onto a neighboring bell jar holds another page frozen and dark, fog drifting over a reflective floor

You spent weeks shaving your Largest Contentful Paint. You self-hosted the fonts, inlined the critical CSS, and got the hero image under 60 KB. Then a reader taps a link, taps back, and your page loads again from scratch — network, parse, layout, the whole thing.

It did not have to. Every modern browser can restore that page instantly, from memory, with zero requests and zero JavaScript re-execution. The feature is called the back/forward cache, usually shortened to bfcache, and it is on by default. Your site may be opting out of it without anyone having decided to.


What the bfcache actually stores

The HTTP cache stores responses. The bfcache stores the page itself: the live DOM, the JavaScript heap, the scroll position, the half-typed text in the search box, the state of every open disclosure. When you navigate away, the browser freezes the whole thing instead of tearing it down. When you come back, it thaws it.

That is a different category of fast. An HTTP cache hit still has to parse HTML, run scripts, and lay out the page. A bfcache restore skips all of it. There is nothing to measure because nothing happens.

Back and forward navigations are not an edge case, either. Chrome’s own published data puts them at roughly one in ten navigations on desktop and one in five on mobile. On a content site, where the pattern is “open the index, read a post, go back, read another,” the share is often higher. Every one of those is either instant or a full reload, and you control which.


Static sites start eligible — then break it

A plain static page is close to the ideal bfcache candidate. No open connections, no server session, no in-flight writes. The browser has every reason to keep it.

What breaks eligibility is almost never the page. It is something bolted onto it. The common blockers:

  • An unload event listener. The browser cannot promise the page will get its unload event if it might be frozen instead of destroyed, so historically the presence of a listener has disqualified the page. Chrome is actively deprecating unload for exactly this reason. You probably never wrote one. Your analytics snippet, your chat widget, or a five-year-old A/B testing script might have.
  • Cache-Control: no-store on the HTML. Browsers have long treated no-store as “never keep a copy of this,” and that has included the frozen page. Some are experimenting with loosening the rule, but you should not count on it. This one is usually a hosting or CDN default, not a deliberate choice.
  • Open connections. A live WebSocket or WebRTC connection at the moment of navigation will usually block the cache. On a static site that almost always means a third-party embed — live chat, a real-time comment counter, a presence indicator.
  • A window.opener relationship. A page opened with window.open() that still holds a reference to its opener, or is referenced by one, can be excluded.

Notice the pattern. Three of those four are introduced by third-party code, which is one more reason an embed is not a component. The script does not just cost you its own bytes; it can cost you every back navigation on the page.


no-cache is not no-store

The header mix-up deserves its own section, because it is the easiest one to fix and the most common one to get wrong.

Cache-Control: no-cache     # store it, but revalidate before reuse
Cache-Control: no-store     # never store it anywhere

no-cache is what you usually want for HTML on a static site: the browser keeps a copy, checks with the server via ETag or Last-Modified, and gets a cheap 304 when nothing changed. It does not block the bfcache.

no-store is for responses that genuinely must never touch disk — account pages, banking dashboards, anything with personal data. A public blog post is not that. If your host or framework adapter is sending no-store on static HTML, it is costing you the bfcache for no security benefit at all. We walked through setting these per path in Cache-Control Is the Config File You Never Wrote; check the HTML rule specifically.

curl -sI https://example.com/posts/some-post/ | grep -i cache-control

Ban unload at the header level

You can audit every script for unload listeners, or you can tell the browser to ignore them. Chrome supports an unload feature in Permissions Policy, and turning it off means any unload listener on the page — yours or a vendor’s — is simply never registered.

Permissions-Policy: unload=()

On a static host that is one line in your headers file:

/*
  Permissions-Policy: unload=()

If some script genuinely needs to do work when the user leaves, it should listen for pagehide or visibilitychange instead. Both fire reliably, including on mobile, where unload often does not fire at all. pagehide is the direct replacement:

window.addEventListener('pagehide', (event) => {
  // event.persisted is true when the page is going into the bfcache
  navigator.sendBeacon('/api/leave', JSON.stringify({ persisted: event.persisted }));
});

Restored pages are stale pages

The bfcache has a cost that nobody mentions until it bites: a restored page is exactly as you left it, including state that is now wrong.

The classic static-site version is the form that disables its submit button on submit. The user submits, the browser navigates to the thank-you page, the user taps back, and the form is restored with the button still disabled. It looks broken, because it is.

Other versions:

  • A mobile nav menu that was open when the user tapped a link is still open when they come back.
  • A theme toggle changed on another page has not been applied to the restored one.
  • A “last updated” relative timestamp still says “just now” an hour later.

The fix is the pageshow event, which fires on every page display and tells you whether the page came out of the bfcache:

window.addEventListener('pageshow', (event) => {
  if (!event.persisted) return;

  document.querySelectorAll('form button[type="submit"]').forEach((button) => {
    button.disabled = false;
  });
  document.documentElement.dataset.theme = localStorage.getItem('theme') ?? 'dark';
  document.querySelector('[data-nav]')?.removeAttribute('open');
});

Do not respond to stale state by opting out of the bfcache. Reset the three things that go stale and keep the instant navigation.


Your analytics are undercounting

A bfcache restore does not reload the page, so it does not re-run the snippet that fires your pageview. If your analytics only record on initial load, every back navigation is invisible.

That is not always wrong — some people would rather not count a return to the same page as a new view. But decide on purpose. If you do want those views, count them from pageshow:

window.addEventListener('pageshow', (event) => {
  if (event.persisted) recordPageview({ restored: true });
});

The same applies to any home-rolled performance beacon. A restored page never produces a new LCP entry in the normal way, so tools that report Core Web Vitals have to handle bfcache restores explicitly. The web-vitals library does; a hand-written PerformanceObserver usually does not.


How to check your own site

You do not have to guess which blocker you have. The browser will tell you.

  1. Chrome DevTools. Open the Application panel, find Back/forward cache, and run the test. It navigates away and back, then lists every reason the page was not restored, including which frame caused it.
  2. Lighthouse. Recent versions include an audit for pages that prevent back/forward cache restoration, which means it can run in the same CI job you already use. See Lighthouse CI Without a SaaS.
  3. Real users. In Chromium browsers, the navigation timing entry can report why a restore did not happen:
const [nav] = performance.getEntriesByType('navigation');
if (nav?.type === 'back_forward' && nav.notRestoredReasons) {
  console.log(nav.notRestoredReasons);
}

If a navigation has type back_forward at all, the page loaded normally, which means the bfcache missed. A hit never creates a new navigation entry. That makes the ratio easy to reason about: every back_forward entry you see in the field is a restore you lost.


The takeaway

Static sites are born eligible for the fastest page load the web has. Keeping that eligibility takes three habits:

  • Send no-cache, not no-store, on public HTML.
  • Set Permissions-Policy: unload=() so no script can quietly opt you out.
  • Handle pageshow with persisted for the handful of things that go stale.

Then run the DevTools test on your heaviest template — the one with the embeds. If it passes, every back tap on your site costs zero bytes and zero milliseconds. If it fails, the reason is on the screen, and it is almost never your own code.