Where cache lives: how many copies of one response exist at once

The same server response can sit in several caches at once - app memory, the Service Worker, the HTTP cache, the CDN - and the whole page is held by bfcache on top of that. Each has a different owner and something different clears it, so "clear cache" hits one of them, sometimes none. What's left is the question every such debugging session starts with: which layer is holding the stale copy, and what invalidates it.
You ship a fix and do a hard refresh (Ctrl+Shift+R, or Cmd+Shift+R on a Mac) to make the browser pull the page fresh - and it’s still the old version. First thought: “clear cache” - and it usually helps. But “clear cache” isn’t one action, it’s a question: which one? Because the moment you’re staring at that stale page, the same server response is sitting in several stores at once, and “clear” hits one of them, sometimes none.
One thing sets these stores apart, and right now it’s the one that matters: what removes the stale copy from each. From one it disappears on its own after a normal reload, from another no button in the browser will clear it, and the third you can’t reach from the browser at all. So let’s not clear everything blindly. Let’s treat it as a hunt for the culprit: each store gives itself away by something different, and a few simple questions rule out the suspects one by one.
#First question: does a normal reload fix it?
Start with the cheapest move: reload the page with a normal refresh (F5). If the old version is gone, the culprit was in the page itself - in app memory.
This is the first “cache” - though really no cache at all, because it isn’t a built-in mechanism of the browser or the server, just your own code. A module-level variable that holds a once-fetched list for as long as the page is open. A shared app-state store (Redux, Zustand) where you saved a response so a second component wouldn’t fetch it again. A query cache in a data-fetching library (React Query, SWR) - underneath it’s the same thing: a result remembered under a key and handed back without touching the network. All of it lives in the open page’s memory and dies with it, so a full reload wipes it to zero before anything lower down gets a word in. (Exception: if you deliberately persist that cache - to localStorage or IndexedDB - it survives a reload; then rule that layer out first.) That’s why so many bugs “disappear after a refresh” - and why this layer is the easiest to forget, because nobody calls their variable a cache until it hands back stale data.
#Reload didn’t help? We go deeper
If the old version holds on despite a reload, the copy sits deeper than the page’s memory - on disk or in the network. Here the second group of suspects begins, each with a different hiding place; we go in order, from what’s closest to your code outward toward the server.
First the Service Worker. If you wrote one, it intercepts the request before everything else and can keep serving files from its Cache API forever, because that store is cleared only by your code - cleanup in activate and a bumped version in the cache name. And here’s the trap that catches the most people: “clear cache” in the browser doesn’t touch it, and a hard refresh at most bypasses it for a single load (Chrome then loads the page as if the worker weren’t there) - but it doesn’t clear it, so on the next normal visit you get the stale version again. Open Application → Service Workers in DevTools; if an old worker is sitting there, it’s the culprit, not any header.
No Service Worker, or it’s not the one? That leaves the HTTP cache, the one driven by the server’s headers. Despite the name it’s not one store but two: freshly used responses sit in the memory cache (in RAM, for the tab’s lifetime), the rest on disk in the disk cache. In the Network tab you’ll recognize them by the (memory cache) or (disk cache) note next to a file. Here the stale copy is removed by the same mechanism as in part one - freshness and revalidation: the copy expires, the browser asks the server, and if there’s a new version it fetches it. A hard refresh works directly here: it sends requests with a header that tells the browser to skip both HTTP layers and fetch everything fresh. That’s why it helps for you but not on someone else’s screen - because the user won’t press that shortcut.
And if even a hard refresh doesn’t help? Then the stale copy is already off your machine. The first stop in the network isn’t the origin (your source server) but the CDN - a shared cache that holds one copy for many users, with its own lifetime (set by the s-maxage header). You can’t clear this copy from the browser with any shortcut; it goes away when its lifetime expires or when someone manually clears it (a purge) on the CDN itself. That’s why a deploy often ends with an automatic purge - without it, the fresh file from the origin won’t get past the CDN, which keeps serving the old one. This mostly concerns HTML and API responses; files with a hash in the name (from part one) don’t need a purge, because new content means a new URL the CDN doesn’t have yet anyway.
#A suspect off the list: bfcache
There’s one more store, but it doesn’t fit the investigation above, because it doesn’t sit on the path of any request. All the previous ones answered the question “do I have this file?”. This one answers something completely different: “do I have this whole page?”.
This is bfcache (back/forward cache). When you move to another page (click a link or go forward), the browser doesn’t throw the previous one away right away. It freezes it whole - the DOM tree, the JavaScript state, the scroll position - and sets it aside. You click “back” and the page returns in a fraction of a second, exactly as you left it, because nothing is rebuilt. No request goes out, because it’s not a copy of a file but a frozen, live page. The symptom here is often the reverse of the rest of the post: not “old content despite a refresh” but “after going back I see a state that shouldn’t be there anymore” - a stale form, an unrefreshed balance.
It’s the only store you don’t configure to your liking. You don’t choose what lands in it or for how long - the page either qualifies or it doesn’t. Your job is just not to break it by accident.
#The map: who holds the copy and what removes it
The whole investigation in five rows - for each store, what empties it:
| Store | What it holds | What empties it |
|---|---|---|
| app memory | data in app state | a page reload, or your code |
| Service Worker | request/response pairs | cleanup in activate + a new cache name |
| HTTP cache (RAM + disk) | responses with headers | expiry of max-age/ETag; a hard refresh on your side |
| CDN | public responses | s-maxage, or a manual purge |
| bfcache | a snapshot of the whole page | no-store, unload, the next navigation |
Your eye goes to the last column, because that’s the crux. Each row is cleared differently and has a different owner: the HTTP and CDN headers are set by the server, the Service Worker and app memory by you in code, the hard refresh by the user. “Clear cache” in the browser reaches only some of them, which is why it so often doesn’t help.
Try it yourself: set which layer the stale copy is stuck in and see what each action actually does - and what “clear cache” doesn’t touch at all.
Pick where the stale copy is hiding and what you do to clear it. See whether that action even reaches that layer.
The Service Worker is cleared only by your code: cleanup in activate and a new cache name. A hard refresh bypasses it for a single load but doesn't clear it - on the next normal visit it serves stale again.
bfcache stands apart here - it doesn't sit on the request path, so none of these actions apply to it.
#Where to start next time
Don’t start by clearing everything. Start with the questions that rule out the suspects one by one:
- Reload. Old version gone? It was app memory - your state, not any header.
- Still stale? Open Application → Service Workers. An old worker sitting there? That’s it, and “clear cache” won’t touch it.
- No worker? Check in Network whether the file comes from
disk cacheand whatCache-Controlit has. Here freshness rules, and a hard refresh gets around it. - Even a hard refresh doesn’t help? The copy is off your side, on the CDN - only a purge over there will move it.
- Not “stale despite a refresh” but a strange state after “back”? Then it’s bfcache, not a file cache.
“I cleared the cache and it’s still stale” isn’t one problem but five different suspects. Each is cleared differently, so the point isn’t to clear everything but to know which one is holding the stale copy.
Comments
Loading comments…