bfcache Not Working? Find the Blocker, Then Remove It

When bfcache is not working for a page, the cause is almost always one of four things: an unload listener, a Cache-Control: no-store header on the HTML document, a connection still open (WebSocket, IndexedDB, an in-flight fetch) at the moment the user leaves, or a window.opener reference. Chrome will tell you which. Open DevTools, go to Application, then Back/forward cache, and click Test back/forward cache. The panel lists every reason the page was not restored and marks the ones you can fix as Actionable.

Why chase it?

The back/forward cache (bfcache) is the browser keeping a snapshot of your page in memory, JavaScript heap included, when the user navigates away. Press back and the page reappears in the state they left it: no network request, no script re-execution. Chrome’s usage data puts back and forward navigations at roughly one in ten on desktop and one in five on mobile. Each one that misses the cache is a page load you paid for twice, on the navigation users expect to be instant.

Nothing about bfcache is opt-in. A page is eligible unless it does one of a short list of things, so you do not add support for it. You stop blocking it.

How do I find out why bfcache is not working?

Two ways, one for your machine and one for your users.

On your machine, run the DevTools test. Chrome navigates to chrome://terms/ and back, then reports either “Successfully served from back/forward cache” or a list of reasons grouped as Actionable, Pending Support and Not Actionable. Expand the Frames section if the culprit is an iframe rather than the top frame. Lighthouse 10 and later runs the same check as an audit.

For real users, read notRestoredReasons off the navigation timing entry. It is null on a non-history navigation and undefined in browsers that do not support it, so guard for both:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const nrr = entry.notRestoredReasons;
    if (!nrr) continue; // not a back/forward navigation, or unsupported

    const reasons = nrr.reasons.map((r) => r.reason);
    navigator.sendBeacon(
      '/analytics/bfcache',
      JSON.stringify({ url: nrr.url, reasons })
    );
  }
});

observer.observe({ type: 'navigation', buffered: true });

As of October 2026 MDN lists notRestoredReasons as limited availability: Chromium ships it, the other engines do not. For monitoring that is enough, because Chromium is also where you can act on the result. The reason strings are documented on MDN’s bfcache monitoring page; the ones you will meet most are unload-listener, response-cache-control-no-store, websocket, fetch, outstanding-network-request and masked. MDN warns the list can change, so bucket on the string rather than switching on it exhaustively.

masked means the real reason is hidden for privacy, usually because a cross-origin iframe blocked the cache. The child frame appears in children with url: null and an empty reasons array. You cannot see what the iframe did, but you can see which iframe it was, which is usually enough to go and ask the vendor.

What do I do about each blocker?

unload listeners

Remove them. Chrome deprecated the unload event and, per its deprecation timeline, finished the rollout to all origins in Chrome 155 in October 2026, so the handler no longer fires there in any case. Firefox on desktop treats a page with an unload listener as ineligible. Safari may cache the page but will not run the handler.

The replacement is pagehide for “the user is leaving” and visibilitychange for “this is the last reliable moment to save state”:

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    saveDraft(); // last reliable point, fires on tab switch too
  }
});

window.addEventListener('pagehide', (event) => {
  if (event.persisted) {
    // the browser intends to cache this page; close what cannot survive it
    socket?.close();
  }
});

Third-party scripts add unload listeners too, and so do browser extensions. The response header Permissions-Policy: unload=() stops any script on the page from registering one. It is Chromium-only and, with the deprecation complete, largely redundant there, but it is a one-line guard against a tag manager undoing your work.

beforeunload is different. Modern browsers no longer treat it as a blocker, but it is unreliable, so register it only while there are unsaved changes and remove it once they are saved.

Cache-Control: no-store on the document

This is the one most people trip over, because a framework default or a CDN rule set it rather than anyone who meant it. Check the headers on the HTML response itself. no-store on a subresource does not matter; no-store on the document does.

Chrome has partly relaxed this. After experiments that began in Chrome 116, Chrome now admits no-store pages to bfcache with conditions: the page is evicted if cookies or other authorisation state change while it is cached, it is excluded if it uses WebSocket, WebTransport or WebRTC at all, it is excluded if it issued a fetch or XHR whose response was also no-store, and the cache timeout drops from ten minutes to three. Chrome’s own figures said no-store was blocking about 17% of mobile and 7% of desktop history navigations before the change, which is why they bothered.

Do not build on the exception. Firefox and Safari do not document one, and the eviction conditions mean a logged-in dashboard is still unlikely to be restored. If the content merely has to be fresh, use no-cache or max-age=0, and keep no-store for pages that genuinely must never sit in any cache. Our post on stale-while-revalidate and Cache-Control for CDNs covers how those directives behave at the edge.

Open connections

Some browsers will not cache a page with an open IndexedDB connection, an in-progress fetch() or XHR, or an open WebSocket or WebRTC connection. Chrome (from version 149, according to web.dev) and Safari no longer block on an open WebSocket; they disconnect it on entry and fire error and close on the socket when the page is restored. Other browsers still refuse. The portable fix is the pagehide handler above, closing the socket when persisted is true, paired with a pageshow handler that reopens it:

window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    socket = connect();   // page came back from bfcache
    refreshPrices();      // anything time-sensitive since the snapshot
  }
});

That pageshow handler is also where anything that would be wrong after a restore belongs: a countdown that stopped, a session that may have expired, a cart badge changed in another tab. pageshow fires after load on a normal navigation too, so the persisted check is what separates the two cases.

window.opener

A page opened with window.open() from another page, or a page that opened one and still holds the reference, cannot be cached safely because the two windows can reach each other’s state. rel="noopener" is the default for target="_blank" links in modern browsers, so this mostly bites older markup and explicit window.open calls. If two windows need to talk, use BroadcastChannel, knowing that a message arriving while the page is cached (broadcastchannel-message) evicts it.

What changes once pages start being restored?

Analytics first. A restore does not fire load or DOMContentLoaded, so a page view counted on either misses every back navigation. Count a pageshow with persisted set to true as a page view if that is what your product means by one. JavaScript is paused while the page sits in the cache, so a timer that was running resumes where it stopped rather than catching up; re-check the wall clock in pageshow rather than trusting elapsed ticks.

Client-side routers do not get any of this for free. bfcache only applies to browser-managed navigations, so a single-page app’s soft navigations never touch it; only the document’s first entry and exit do. If you are prerendering the next page with speculation rules, the two divide the work: speculation covers forward, bfcache covers back.

What would I actually do?

Run the DevTools test on your five most-visited templates today. If the result is Actionable, the fix is usually under an hour: delete the unload listener, correct the Cache-Control header on the HTML, move socket teardown into pagehide. Then ship the PerformanceObserver snippet so you can see the real-user reason distribution; the size of the masked bucket tells you how much of the problem is embedded third parties you can only negotiate with. I would not spend time on Pending Support or Not Actionable reasons, and I would not drop no-store from a page that handles personal or payment data just to make it cacheable. Most content pages can afford the cache. Most account pages cannot, and that is the right trade.

Whoooop does this as part of website speed optimisation work, usually alongside the INP and LCP fixes we have written about elsewhere, because a back navigation that reloads cold undoes all of them. If you want a second pair of eyes on a bfcache test result, that is the place to start.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch