SiteTidy
HomeGuidesbfcache and Cache-Control: Why no-store Is Not a Logout Strategy
Performance

bfcache and Cache-Control: Why no-store Is Not a Logout Strategy

A practical guide to back/forward cache, Cache-Control no-store, logout safety, pageshow, stale application state and debugging bfcache failures in production.

Published on September 8, 2026

Cache-Control: no-store used to have a convenient side effect in Chrome: pages carrying it were not eligible for the back/forward cache.

That made a bad security assumption look reliable. An application could log a user out, rely on no-store, and expect the Back button to force a network request before anything authenticated reappeared.

That is no longer a safe model.

Chrome changed its bfcache handling so that many no-store pages can be restored from memory. The browser adds safeguards around authentication changes, but the larger lesson is more useful than the Chrome-specific change: HTTP cache policy and session invalidation solve different problems.

If an application becomes unsafe when an old document is shown without a server round trip, its logout design is too dependent on navigation behaviour.

bfcache is not the HTTP cache

The name causes a surprising amount of confusion.

The HTTP cache stores responses and may reuse or revalidate them according to headers such as Cache-Control. The back/forward cache (bfcache) is different: the browser can freeze a complete page, including its JavaScript heap and rendered state, then resume that page when the user traverses session history.

Conceptually:

HTTP cache
request -> cached response bytes -> construct page again

bfcache
page A -> freeze live document -> page B
                    |
Back button --------+-> resume page A

A bfcache restoration can therefore preserve much more than response bytes. Form values, DOM state, JavaScript objects and scroll position may all still be there because the old document itself survived.

This is why treating no-store as a universal instruction to “never show this page again” was always mixing two layers.

The Chrome change exposes the architectural mistake

Historically, Cache-Control: no-store prevented bfcache use in Chrome. Chrome began changing that behaviour after experiments starting in Chrome 116 and rolled the change out broadly in 2025.

The reason is pragmatic: no-store is widely used, including on pages where preserving a history entry is both safe and substantially faster than rebuilding it.

Chrome did not simply ignore the sensitivity implied by no-store. Its documented approach adds extra restrictions. In particular, changes to cookies or other authorization state can cause a no-store page to be evicted from bfcache. Chrome also uses a shorter bfcache lifetime for these pages, and certain APIs remain disqualifying in this situation.

Those protections are useful, but I would not turn them into a new application contract.

Browser eviction heuristics are defence in depth. Your authorization boundary should still be the server, and your client should be able to discover that its authenticated state is stale.

A worked logout failure

Consider an account dashboard:

GET /account
Cache-Control: no-store

The page renders:

Balance: £4,280
Recent orders: ...
Email: user@example.com

The user then visits /logout, which invalidates the server session and redirects to the public home page.

A fragile implementation assumes this sequence:

Back
 -> GET /account
 -> server sees invalid session
 -> redirect to /login

With bfcache, there may be no GET /account at that moment. The browser can potentially restore the previous document instead.

The important distinction is between displaying stale pixels/state and retaining server authority.

If logout invalidated the session correctly, restored JavaScript should not be able to perform authenticated server operations. An API call should fail because the credential is no longer valid. But temporarily showing sensitive state can itself be undesirable on a shared device, and an application that assumes every restoration reruns its normal boot sequence can behave incorrectly.

So there are two jobs:

  1. invalidate authority on the server;
  2. make a restored client reconcile its state.

Do both.

load is not a restoration hook

A common SPA bootstrap looks roughly like this:

window.addEventListener('load', async () => {
  await refreshCurrentUser();
});

That handles a normal load. It is not enough for bfcache restoration because a restored document is being resumed, not constructed from scratch.

The useful event is pageshow. Its persisted flag tells you when the document was restored from a page cache:

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

  const response = await fetch('/api/session', {
    credentials: 'include',
    cache: 'no-store',
  });

  if (response.status === 401) {
    location.replace('/login');
    return;
  }

  if (response.ok) {
    const session = await response.json();
    reconcileSession(session);
  }
});

That example deliberately revalidates session state, not the whole application.

The tempting approach is to call location.reload() on every persisted pageshow. It is simple, but it throws away much of the reason bfcache exists. A targeted session check is usually the better compromise for authenticated applications: keep the instant restoration when it is valid, discard or refresh only the state that can become unsafe or misleading.

For highly sensitive screens, choosing to replace or reload the page after restoration can still be reasonable. That is a product/security judgement, not a requirement of bfcache itself.

Do not add unload just to defeat bfcache

Once developers discover that a page is being restored, a predictable reaction is to deliberately make it ineligible.

Adding an unload listener is a particularly poor way to do that.

unload is unreliable, especially on mobile, and browsers have increasingly designed around that unreliability. It can also prevent bfcache eligibility in browsers such as Firefox. You end up damaging navigation performance while depending on an event that is not guaranteed to run.

If you need to persist analytics or small pieces of state as the page becomes hidden, use lifecycle mechanisms designed for that job. visibilitychange is generally a better signal for end-of-session-style telemetry, with pagehide useful when you specifically need a history-navigation lifecycle event. navigator.sendBeacon() exists for small asynchronous telemetry that should not hold navigation open.

beforeunload has a narrower legitimate use: warning about unsaved user changes. Even there, attach it only while there really are unsaved changes rather than globally.

A restored page is old even when authentication is still valid

Security gets most of the attention, but stale application state is the more common production bug.

Imagine this sequence:

1. Open /orders and render 12 orders
2. Navigate to /new-order
3. Create an order
4. Browser Back to /orders
5. bfcache restores the old list showing 12 orders

Nothing is insecure. The UI is simply wrong.

This is where the implementation becomes awkward: forcing every bfcache restoration to perform a full application reload fixes freshness by sacrificing the performance win. Doing nothing preserves speed but can expose stale state.

I would classify state instead:

State On bfcache restoration
Static article/content Usually keep it
Scroll position and draft UI state Preserve it
Authentication/session Revalidate if the screen depends on it
Fast-changing account data Refresh or mark stale
Destructive-action authorization Always enforce again on the server
Analytics page-view state Decide explicitly whether history restoration is a new view

That gives you a much better design than treating the whole page as either “cacheable” or “not cacheable”.

no-cache, no-store and bfcache answer different questions

It is worth separating three ideas:

Mechanism Question it answers
Cache-Control: no-cache May a stored HTTP response be reused only after validation?
Cache-Control: no-store May caches store this HTTP response?
bfcache eligibility May this live document be frozen and later resumed during history navigation?

Do not change a genuinely sensitive response from no-store to a looser HTTP caching policy merely to chase bfcache eligibility. Conversely, do not add no-store to ordinary HTML because you think it is a bfcache opt-out switch.

Choose HTTP caching headers for HTTP caching semantics.

Then make the page lifecycle correct independently.

This same separation of concerns is useful elsewhere in performance work. HTTP 103 Early Hints can start important resource work before the final response, while CSS content-visibility can avoid rendering work below the fold. Neither substitutes for correct navigation lifecycle handling; bfcache attacks a different delay entirely by avoiding reconstruction on history traversal.

Open connections can make restoration harder

A page is not just DOM and local variables. It may own resources that should not remain active while frozen.

Chrome’s Page Lifecycle guidance recommends cleaning up resources when a page is frozen, including open IndexedDB connections, BroadcastChannel connections, WebRTC connections, WebSockets, polling and held Web Locks where appropriate. These resources can interfere with lifecycle behaviour or with other tabs from the same origin.

For an application with a live socket, think in terms of suspension and resumption:

let socket;

function connect() {
  socket = new WebSocket('wss://example.com/live');
}

function disconnect() {
  socket?.close();
  socket = undefined;
}

window.addEventListener('pagehide', (event) => {
  if (event.persisted) {
    disconnect();
  }
});

window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    connect();
    refreshVolatileData();
  }
});

Real applications need guards for duplicate connections and failed reconnects, but the model is the important part: freeze is not termination.

Code that assumes navigation away means “this JavaScript process is gone forever” is exactly the code bfcache tends to expose.

Diagnose misses instead of guessing

There are two different bugs you may be investigating:

  • the page was restored and your application handled restoration badly;
  • the page was not restored and you want to know what blocked bfcache.

Do not debug them as if they were the same problem.

Chrome DevTools includes a back/forward cache test and reports blocking reasons. Lighthouse can also exercise a back/forward navigation and report whether restoration succeeded.

For Chromium-based field diagnostics, PerformanceNavigationTiming.notRestoredReasons can expose why a history navigation failed to restore from bfcache:

const navigation = performance.getEntriesByType('navigation')[0];

if (navigation?.notRestoredReasons) {
  console.log(navigation.notRestoredReasons);
}

The reason strings are not a stable analytics taxonomy. Chrome’s documentation explicitly warns that reasons can be added, removed or renamed. Record the structured diagnostic information for investigation, but do not build product logic that depends on a particular English-ish reason value forever.

Also remember that eligibility is browser-dependent. A page working with bfcache in one browser does not prove that another browser will preserve it under the same conditions.

Test the journey, not just the URL

A normal reload is a poor bfcache test.

Use a sequence that actually creates session history:

A. Load /account
B. Navigate to /settings
C. Change something that affects /account
D. Press Back
E. Observe restoration and reconciliation

For authenticated applications, repeat the journey with logout or credential invalidation between A and D. Test a second tab too, because authentication can change elsewhere while a document is frozen.

Useful assertions include:

  • privileged API calls fail after server-side logout regardless of what is visible;
  • a restored authenticated screen notices an invalid session promptly;
  • volatile data refreshes without unnecessarily destroying preserved UI state;
  • socket/polling connections do not duplicate after restoration;
  • analytics does not accidentally count or omit history restorations contrary to your measurement definition.

That last point is easy to overlook. pageshow occurs for ordinary loads as well as history traversal, so event.persisted matters if you are distinguishing a bfcache restoration from a new document load.

What I would ship

For a conventional authenticated web application, I would keep no-store on responses that genuinely should not enter HTTP caches, but I would stop expecting that header to implement logout UX.

Logout would invalidate the session server-side first. Authenticated pages would have a small pageshow restoration path that revalidates the session and refreshes only volatile data. Sensitive actions would remain server-authorized on every request. Long-lived connections would be lifecycle-aware rather than assuming navigation means process death.

I would also measure bfcache misses before spending engineering time “fixing” them. Some blockers are actionable, some are browser limitations, and some pages simply do not matter enough in back/forward journeys to justify complexity.

The performance goal is not a perfect bfcache score. It is making common history navigations instant without making correctness depend on whether the browser happened to restore a page.

References

Ready to audit your site?

Put this guide into practice immediately using our free tools.

Browse 180+ Free Tools