Things You Should Never Cache
Caching is one of the best tools in frontend performance. Done right, it eliminates redundant network requests, reduces server load, and makes your app feel instant. Done wrong, it leaks private data, serves stale authenticated content, or locks users into broken page states they can’t escape.
The rule is simpler than most articles make it sound: static, versioned, public assets are almost always safe to cache aggressively. Everything else should be treated as unsafe by default.
Before getting into what not to cache, it helps to be precise about where caching happens, because these layers behave very differently.
Key Takeaways
- Cache layers (HTTP, CDN, Service Worker, bfcache, Web Storage) have distinct scopes and security characteristics; treating them interchangeably causes real bugs.
no-storeprevents storage entirely, whileno-cacheonly forces revalidation before reuse.- Authenticated or user-specific responses must use
Cache-Control: private, no-storeto prevent shared caches from leaking data between users. - Never store JWTs, refresh tokens, or API keys in
localStorageorIndexedDB; preferHttpOnlycookies. - Default to
no-storewhenever a stale or shared copy could cause a security or correctness problem.
The Cache Layers Are Not Interchangeable
Most caching mistakes start with treating these as the same thing:
- HTTP/browser cache — Controlled by
Cache-Controlheaders per RFC 9111. Shared between tabs, persists across sessions. - CDN/shared cache — Sits between your users and your origin. Cloudflare CDN and similar services cache responses for all users, not just one.
- Service Worker Cache API — A programmable cache you control entirely in JavaScript. Persists until explicitly cleared.
- bfcache (back/forward cache) — The browser’s in-memory snapshot of a full page, used when navigating back. Separate from the HTTP cache.
localStorage/sessionStorage— Key-value storage. No expiry management, fully accessible to JavaScript.IndexedDB— Structured persistent storage. Same XSS exposure surface aslocalStorage.
Each has different persistence, scope, and security characteristics. Conflating them causes real bugs.
A Quick Note on Cache-Control Semantics
This distinction is widely misreported, so it’s worth stating clearly:
no-storemeans the cache must not store the response at all.no-cachedoes not mean “don’t cache.” It means the cached copy must be revalidated with the server before reuse.
If you want a response never stored anywhere, use no-store. If you want freshness guaranteed on every use but still want the efficiency of conditional requests (ETags, 304s), use no-cache.
What You Should Never Cache
Authenticated API Responses and User-Specific HTML
Any response that varies by user identity — dashboard HTML, account pages, API responses containing profile data — should not be cached in a shared cache. A common pattern is:
Cache-Control: private, no-store
no-store prevents normal HTTP caches from storing the response, while private explicitly tells shared caches like CDNs not to cache it for multiple users.
Without private, a CDN like Cloudflare may cache a response returned for User A and serve it to User B. This has caused real data exposure incidents.
Anything Inside a Service Worker That Requires Authentication
The Service Worker Cache API is effectively a programmable network proxy. A service worker intercepts every fetch request in its scope. If you blindly cache authenticated API responses or user-specific HTML inside it, that data persists in the Cache API storage — potentially across sessions, and accessible to the service worker regardless of login state.
// ❌ Don't do this
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request).then(response => {
return caches.open('v1').then(cache => {
cache.put(event.request, response.clone());
return response;
});
})
);
});
This pattern caches everything indiscriminately. Exclude authenticated routes explicitly:
// ✅ Only cache public, versioned assets
const CACHEABLE = ['/shell.html', '/app-abc123.js', '/styles-f9c.css'];
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) return; // ignore cross-origin
if (!CACHEABLE.includes(url.pathname)) return; // let it go to network
event.respondWith(
caches.match(event.request).then(r => r || fetch(event.request))
);
});
Secrets in localStorage or IndexedDB
The OWASP HTML5 Security Cheat Sheet is explicit: storing JWTs, refresh tokens, API keys, or password-reset tokens in localStorage or IndexedDB is risky because any XSS vulnerability on your domain can read them. These storage mechanisms have no built-in expiry, no HttpOnly equivalent, and no isolation from injected scripts.
Use HttpOnly, Secure, SameSite cookies for tokens where possible. If you must use localStorage, understand you’re accepting that risk.
Discover how at OpenReplay.com.
User-Specific Responses at the CDN Layer
A common Cloudflare CDN mistake: forgetting the private directive on responses that include user data, or misconfiguring Vary.
# ❌ Missing private — CDN may cache this for all users
Cache-Control: max-age=300
Content-Type: application/json
# ✅ Correct
Cache-Control: private, no-store
If your response legitimately varies by a request header (like Accept-Language or Authorization), you must include a correct Vary header. Without it, a CDN may serve a cached French-language response to an English-speaking user, or worse, a cached authenticated response to an unauthenticated one.
Also watch for cache poisoning via unkeyed parameters: if your CDN caches based on URL but your app reads an unvalidated query parameter or header to construct the response, an attacker can poison the cache with a crafted request.
Pages That Should Not Survive bfcache
Chromium-based browsers have, in recent versions, become more willing to use bfcache even on pages that send Cache-Control: no-store. This behavior is browser-dependent and not universally consistent across Chrome, Firefox, and Safari. caniuse shows broad modern browser support for bfcache, but the interaction with no-store differs between engines and browser versions.
If you have pages where restoring a stale in-memory snapshot is genuinely unsafe — such as a page showing a one-time payment confirmation or a session-sensitive state — use the pageshow event to detect bfcache restores and reload or re-validate:
window.addEventListener('pageshow', event => {
if (event.persisted) {
// Page was restored from bfcache
window.location.reload();
}
});
Do not assume no-store universally disables bfcache across all browsers.
The Default Mental Model
If you’re unsure whether something should be cached, ask: could serving a stale or shared copy of this cause a security or correctness problem? If yes, default to Cache-Control: no-store and work backwards from there.
Static assets with content-hashed URLs — your JavaScript bundles, CSS files, images — are safe to cache for a year with max-age=31536000, immutable. Everything tied to a user session, authentication state, or sensitive operation is not.
Conclusion
Aggressive caching is a feature. Accidental caching of the wrong things is a vulnerability. The discipline lies in knowing which layer you’re targeting, what each Cache-Control directive actually guarantees, and which categories of data should never leave the origin’s tight control. Treat anything tied to a user, a session, or a secret as uncacheable by default, and reserve aggressive caching for the static, versioned assets it was designed for.
FAQs
It is possible but not recommended. Any XSS vulnerability on your domain gives an attacker full read access to localStorage, which means they can steal the token and impersonate the user. HttpOnly, Secure, SameSite cookies are safer because JavaScript cannot read them directly. If you must use localStorage, you are accepting the XSS risk and should invest heavily in CSP and input sanitization.
no-store forbids normal HTTP caches from storing the response for reuse. no-cache allows the response to be stored but requires revalidation with the origin before each reuse, typically through ETag or Last-Modified conditional requests. Use no-store for sensitive data and no-cache when you want freshness guarantees but still benefit from 304 Not Modified responses.
Usually because the response lacks the private directive or has an incorrect Vary header. Without private, a shared cache treats the response as cacheable for everyone. Without Vary on headers like Authorization or Cookie, the CDN ignores those headers when generating cache keys and may serve User A's response to User B. Always send Cache-Control: private, no-store on user-specific responses.
There is no reliable cross-browser header that disables bfcache. Cache-Control: no-store works in some browsers but not consistently. The reliable approach is to listen for the pageshow event and check event.persisted. If true, the page was restored from bfcache and you can force a reload or re-fetch sensitive state. This is essential for payment confirmations and post-logout pages.
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.