no-store and the Browser Back/Forward Cache

Problem Statement

A banking application marks every authenticated page no-store, which is correct for keeping response bodies off disk. It also means pressing the back button triggers a full navigation and a full re-render on every page in the flow, where other sites restore instantly. The directive is doing exactly what it says; the cost is larger than intended because it reaches beyond the HTTP cache.

Prerequisite Concepts

Step-by-Step Resolution

Step 1 — Understand what the restore actually is

The back/forward cache is not the HTTP cache. It holds a fully-constructed page — DOM, JavaScript heap, scroll position — in memory so that a back navigation can resume it rather than rebuild it. Because it retains the constructed page rather than a response body, a document marked no-store is disqualified: the browser has been told not to retain this response, and a suspended page is retention.

Two different caches, one directive affecting bothThe HTTP cache stores response bytes for reuse; the back/forward cache suspends a fully-constructed page in memory. no-store disqualifies a document from both.HTTP cachestores response bytesReused on a fresh request for the same URLSurvives a browser restart when written to diskGoverned directly by Cache-ControlBack/forward cachesuspends the built pageReused only on back or forward navigationHeld in memory, discarded on restartDisqualified by no-store on the document
The directive reaches further than the HTTP cache

Step 2 — Measure what it costs

The difference between a restore and a navigation is large enough to be worth quantifying before deciding:

Back navigation cost, with and without the restoreA restored page resumes from memory with no network activity at all; a disqualified page repeats the full navigation including authentication and rendering.Restored from memory40ms no network at allRevalidated document420ms conditional requestno-store, full navigation1180ms auth plus rendermeasured on an authenticated account page
Time to interactive on a back navigation

Step 3 — Move the directive onto the data

The sensitive payload is rarely the whole document. Splitting lets each part carry the protection it needs:

GET /account/statements              -> Cache-Control: private, no-cache
GET /api/account/statements.json     -> Cache-Control: no-store

The document is a shell — layout, navigation, an empty table — that contains nothing worth protecting and is eligible for restore. The payload never touches storage.

Step 4 — Clear or refresh on restore

A restored page reinstates whatever was on screen when the user left, including data that has since been fetched. Handle the restore event explicitly:

window.addEventListener("pageshow", (event) => {
  if (!event.persisted) return;          // ordinary load, nothing to do
  // Restored from the back/forward cache: re-fetch rather than trust the DOM
  document.querySelector("#statements").replaceChildren();
  refreshStatements();
});

This also solves a correctness problem the HTTP directives never addressed: a restored page can show data that was accurate when the user navigated away and is not any longer.

Choosing the protection for each part of a pageThe document, the payload and the credential need different treatment, and applying the strictest directive to all three costs more than it protects.DirectiveRestore eligibleWhat it protectsDocument shellprivate, no-cachenothing sensitive in itData payloadno-storenot applicablethe sensitive fieldsSession tokenno-storenot applicablethe credential itselfStatic assetspublic, immutablenothing
Three parts of one page, three different directives

Step 5 — Verify both properties

The two requirements are independent and both need checking:

# The payload must not be storable
curl -sI https://example.com/api/account/statements.json | grep -i cache-control
# expect: Cache-Control: no-store

# The document must be restore-eligible
curl -sI https://example.com/account/statements | grep -i cache-control
# expect: Cache-Control: private, no-cache

Restore eligibility itself is confirmed in the browser: navigate away, press back, and check whether the pageshow event reports persisted as true. Browser developer tools also report the specific reason a page was disqualified, which is the fastest way to find a blocker that is not the directive.

Step 6 — Decide the trade explicitly rather than by default

Sites reach the all-no-store configuration by accident far more often than by decision. It is the safest-looking option, it passes any review that asks “is sensitive data cached”, and its cost is invisible in every metric except the one nobody measures — how long a back navigation takes.

Making the decision explicit takes one conversation with whoever owns the security requirement. The question to put to them is narrow: does the requirement concern the response bytes reaching persistent storage, or does it concern a fully-rendered page remaining in memory for the duration of the tab’s life? The first is what no-store was designed for and the split preserves it exactly. The second is a different requirement, rarely what a policy actually says, and it does genuinely rule out the restore.

Recording which answer applies, per route, converts an inherited default into a decision that can be revisited. It also tends to shrink the strict list, because most requirements turn out to be about persistence rather than about memory.

Expected Output / Verification

A correctly split page restores instantly on back navigation, re-fetches its data on restore, and leaves nothing sensitive in storage:

back navigation:  pageshow persisted=true, 41 ms to interactive
payload request:  Cache-Control: no-store, not present in disk cache
document:         Cache-Control: private, no-cache, restored from memory

Edge Cases

  • An unload handler blocking the restore. A legacy unload listener disqualifies the page regardless of the directives. Use pagehide instead, which is compatible with the restore.
  • Open connections. An unclosed WebSocket or an in-flight request can prevent suspension. Close or abort them in pagehide and re-establish in pageshow.
  • Session expiry during suspension. A page restored an hour later may hold a session that has since expired. Re-validating on restore is what turns this from a confusing error into a clean redirect.
  • Third-party scripts blocking eligibility. An embedded widget holding a connection can disqualify pages the application itself made eligible, and the reason will not be visible in your own code.
  • Assuming restore implies staleness is safe. The restore reinstates the DOM exactly as it was. Anything time-sensitive on screen is now as old as the suspension, which the HTTP directives have no bearing on.
  • Regulatory requirements naming the document. Where a rule explicitly requires that no part of an authenticated page be retained, the split is not available and the navigation cost has to be accepted.

Frequently Asked Questions

Does no-store on a document really disable the back/forward cache?

Yes. Browsers treat a no-store document as ineligible for the whole-page restore, because the restore reinstates a fully-constructed page from memory — exactly the retention the directive asks them to avoid.

Is the back/forward cache worth protecting?

On navigation-heavy sites it is one of the largest available wins, because a restore is effectively instantaneous compared with a full navigation. Whether it outweighs the data-protection requirement is a per-route decision, not a site-wide one.

Can I keep the restore and still protect the data?

Usually. Put no-store on the API responses that carry the sensitive payload rather than on the surrounding document, and clear or re-fetch that data when the page is restored.

What else disqualifies a page from restore?

An open connection the browser cannot suspend — an unclosed WebSocket or an in-flight fetch — an unload handler, and certain permission or media states. Browsers expose the reason in their developer tools, which is the quickest way to find out.


Back to no-cache vs no-store