Cache-Control: no-cache Does Not Mean Do Not Cache. And No Header At All Is Worse.
no-cache stores the response and revalidates it every time - no-store is the one that refuses to store. And an index.html with no Cache-Control at all is still cached, for a period the browser works out from Last-Modified. Reproduced in Chromium, with the two-rule setup I would use.
I Shipped v2. The Browser Showed v1. The Server Never Heard A Thing.
The test page was one line of HTML: an <h1> that said v1. The server sent it with an ETag and a Last-Modified date from ten days earlier, which is what you get from basically any static host when the file was last deployed ten days ago. No Cache-Control header at all. I had not configured one, so in my head it was "not cached".
I opened it in Chromium, navigated away, then flipped the server to v2. New content, new ETag, a fresh Last-Modified. A normal deploy. Then I navigated back to the URL, the same way a user clicks a bookmark or a link in Slack.
v1. And the server log for that visit was empty. Not a 304. Not a conditional request. Nothing. The browser did not ask. It decided the page it already had was fresh and rendered it from disk.
A normal reload fixed it, which is exactly why this kind of bug is so annoying to chase. It shows up as "some users still see the old version", you reload, you see the new one, and you close the ticket as "cache, probably".
It is cache. But it is not random, and it is not the CDN's fault. It is the HTTP caching spec doing what it says, and most of us have never read the part that applies. There are two separate misunderstandings tangled up here:
- Leaving out
Cache-Controldoes not mean "do not cache". It means "browser, you work out how long to cache this". Cache-Control: no-cachedoes not mean "do not cache" either. It means "store it, but check with me before every use".
No header is not a policy. It is you letting the browser pick one for you.
The rest of this piece is what I measured, why each result happened, the one rule I would put on every deploy, and the handful of directives I keep seeing copy-pasted without anyone knowing what they do.
The Browser Did Maths On Your Last-Modified Date
HTTP caching works on one question: is this stored response still fresh? If it is fresh, the cache can use it without talking to the server. If it is stale, the cache has to revalidate it first, which usually means a conditional request that comes back as a cheap 304 Not Modified.
Freshness normally comes from you. max-age=600 means fresh for 600 seconds. An Expires date means fresh until then. But when a response has neither, RFC 9111 (the current HTTP caching spec) lets caches pick a freshness lifetime themselves. Section 4.2.2 calls it heuristic freshness, and it suggests a typical approach: if there is a Last-Modified header, use 10% of the time since it was last modified.
Chromium implements that 10% rule. So the maths on my test page was simple:
# response headers, no Cache-Control, no Expires Date: Thu, 24 Sep 2026 06:00:00 GMT Last-Modified: Mon, 14 Sep 2026 06:00:00 GMT # 10 days earlier age_of_file = Date - Last-Modified = 10 days freshness = 10% of age_of_file = 1 day # for the next ~24 hours the browser may reuse this # without sending a single byte to your server
Look at what that does to a real release process. The longer you go between deploys, the longer the browser assumes your HTML will stay the same. A page that has not changed in 30 days gets roughly three days of free caching. Then you ship an urgent fix, and anybody who visited in the last three days can keep seeing the old page until their cached copy ages out, gets evicted, or they hit reload.
That is backwards from what you want. The file you most need to update quickly - index.html, the one that points at every other asset - is the one getting cached on a guess.
RFC 9111 allows heuristic caching for responses whose status code is "heuristically cacheable" (200 is one of them) when there is no explicit expiry. It is not a browser bug and it is not specific to Chromium. The 10% figure is the spec's suggested approach, not a hard rule, so other caches can choose differently. The fix is the same everywhere: stop letting them guess.
This is also why "we do not set cache headers, so nothing is cached" is one of the most expensive beliefs in frontend infrastructure. Plenty of origins - a bare nginx, an S3 bucket, a quick Express static server - send Last-Modified by default and nothing else. Every one of them is opting into heuristic caching without saying so.
no-cache, no-store And max-age=0 Are Three Different Things
Here is the part the names get wrong. There are three directives people reach for when they mean "do not serve my users stale stuff", and they do different things.
| Directive | Stored? | Reused without asking? | What it actually means |
|---|---|---|---|
no-store | No | No | Do not keep any copy. Download the whole thing every time. |
no-cache | Yes | No | Keep a copy, but revalidate with the server before every use. |
max-age=0 | Yes | No (stale immediately) | Fresh for zero seconds. Revalidate before reuse, with some edge cases. |
| (nothing) | Yes | Yes, for a guessed period | Heuristic freshness from Last-Modified. |
no-cache is the one with the misleading name. It is really "no-reuse-without-checking". The browser keeps the body, and when it needs the resource again it sends If-None-Match with the stored ETag. If nothing changed, the server replies 304 with no body and the browser uses its copy. You pay one round trip, but not the download.
That is almost always what you want for HTML. You get correctness - every visit checks with the server - and you still skip most of the bytes when nothing changed.
no-store is the real "do not cache". Nothing is kept, so every visit pays for the full response. For a bank statement or a page with personal data on a shared machine, that is the right call. For your marketing homepage, it is paying for the same kilobytes over and over.
max-age=0 behaves like no-cache most of the time in a browser, and in my tests it produced identical requests. The difference is on paper: a stale response can, under some conditions, be served without revalidation - for example when a cache is disconnected from the origin - unless you forbid it with must-revalidate. no-cache has no such escape hatch. If you mean "always check", say no-cache.
no-cache controls reuse. no-store controls storage. Mixing them up means you either cache things you wanted gone, or you throw away perfectly good caching for no reason.Six Policies, One Chromium, And A Server Log
To stop arguing from memory, I wrote a tiny Node server with one route per policy. Each route served an HTML page that loaded one script, both sent with the same Cache-Control, an ETag, and a Last-Modified ten days in the past. Then Playwright drove headless Chromium 147 through the same sequence for each policy: first visit, navigate away, visit again, reload, navigate away, press Back. The server logged every request and whether it was conditional.
| Policy | Revisit (normal navigation) | Reload | Back button |
|---|---|---|---|
| No header | Nothing sent | HTML revalidated, script not | Nothing sent |
no-cache | HTML + script revalidated (304) | Both revalidated | Nothing sent |
no-store | Both fully downloaded | Both fully downloaded | Both fully downloaded |
max-age=0 | Both revalidated (304) | Both revalidated | Nothing sent |
max-age=600 | Nothing sent | HTML revalidated, script not | Nothing sent |
max-age=31536000, immutable | Nothing sent | HTML revalidated, script not | Nothing sent |
Three things in that table surprised me.
No header behaved exactly like max-age=600. Same row, same requests. For a file that is ten days old, the browser's guess was a day of freshness, so within the test it was indistinguishable from a policy someone deliberately wrote.
Reload only revalidated the page. On an ordinary reload, Chromium revalidates the main document and reuses subresources that are still fresh. That has been Chromium's behaviour since around 2017, when the team changed reload to stop revalidating every asset on the page. So "just reload" fixes stale HTML but will not necessarily fix a stale script with a long max-age.
The Back button did not revalidate even no-cache. I checked, and it was not the back/forward cache - the page fired pageshow with persisted: false, so it was a real load served from the HTTP cache. RFC 9111 explicitly allows this: history navigation is meant to show what the user saw before, not fetch a new copy. Only no-store forced a full refetch, because there was nothing stored to show.
This is one browser engine, headless, on a local server. Firefox and Safari follow the same spec but make different choices in the places the spec leaves open, like the heuristic percentage and reload behaviour. Treat the table as "this is what happens in Chromium", not "this is what happens everywhere".
Only One Policy Delivered v2 On The Next Visit
The table above counts requests. What users actually care about is whether they get the new release. So I repeated the deploy scenario from the opening for three policies: visit v1, navigate away, flip the server to v2, navigate back.
| Policy on the HTML | Next visit after deploy | After reload |
|---|---|---|
No header (Last-Modified 10 days old) | v1 | v2 |
max-age=600 | v1 | v2 |
no-cache + ETag | v2 | v2 |
That is the whole argument in three rows. max-age=600 at least has a known ceiling: ten minutes after a deploy, everyone is on the new HTML. No header has an unknown ceiling that grows with the time between your deploys. no-cache has no staleness window at all, at the price of one small conditional request per visit.
For the HTML entry point of a single-page app, I think that price is a bargain. The document is usually small, the 304 has no body, and it is the one request that decides which version of your app the user runs. Everything else should be cached aggressively, and the next section is how you get to do that safely.
Your HTML is not an asset. It is the pointer to your release. Cache the pointer and you have cached the release.
Revalidate The Pointer. Cache The Hashed Files Forever.
Every modern bundler - Vite, webpack, Rollup, Next.js, esbuild - can put a content hash in output file names: index-4f9a1c.js, app-b8985194.css. If the content changes, the name changes. That means a hashed URL is effectively immutable: whatever lives at that URL today will live there forever.
That gives you a clean split with exactly two rules:
In nginx it looks like this. The same idea translates to CloudFront behaviours, Netlify's _headers file, Vercel's headers config or an S3 object's metadata:
# Hashed build output: the name changes when the content does location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; } # Everything that is not hashed, and the HTML entry point location / { add_header Cache-Control "no-cache"; try_files $uri /index.html; }
A few details decide whether this works in practice:
- Only hashed files get the one-year rule.
favicon.ico,robots.txt,manifest.webmanifestand anything in apublic/folder keep their names across releases. Give them a year and you cannot change them. - Keep ETags on the HTML.
no-cachewithout a validator means every revalidation downloads the full body. nginx sends ETags for static files by default. Check yours does. immutableis a bonus, not the mechanism. It comes from RFC 8246 and tells the browser not to revalidate on reload. Firefox and Safari honour it. Chromium does not implement it, largely because its reload already skips revalidating fresh subresources - which is what my table showed. Themax-agedoes the real work.- The service worker script is its own case. Browsers already bypass the HTTP cache for update checks on the worker script by default, but serving
sw.jswithno-cachecosts nothing and removes a variable. I went through the full lifecycle in my previous piece on service workers.
Stale HTML Breaks Loudly When You Delete Old Files
Heuristic caching of HTML would be a minor annoyance if old releases stayed runnable. The reason it turns into a blank screen is a deploy habit that looks tidy: wiping the old build before uploading the new one.
Walk through what happens. A user has v1's index.html cached, heuristically or otherwise. It references app-4f9a1c.js, and that file lazily imports settings-91be02.js when they open the settings page. You deploy v2 and your pipeline syncs the bucket with delete enabled, so every v1 file is gone. The user comes back, gets the cached v1 HTML, and the entry chunk may still be in their cache too. Then they click Settings. The browser requests settings-91be02.js, which was never cached because they never opened that page before, and the origin says 404.
In a Vite app, that surfaces as a failed dynamic import. In webpack-based setups it is the familiar ChunkLoadError. Either way, a route that worked yesterday is dead for that user, and your error tracker fills with an error you cannot reproduce because your own browser has the new HTML.
// cached v1 entry chunk, still running after the v2 deploy const Settings = lazy(() => import("./settings-91be02.js")); // ^ deleted by the v2 deploy -> 404
The fixes stack, and I would do all of them:
no-cacheon HTML, so the window where old HTML exists is as short as possible.- Keep the previous release's assets on the origin for at least a couple of releases. Hashed files do not collide, so leaving them costs a little storage and nothing else. Clean up on a schedule, not on every deploy.
- Handle the failure anyway. Tabs that were open before the deploy are running v1 no matter what your headers say. Vite dispatches a
vite:preloadErrorevent you can listen for, and a single guarded reload is a reasonable response.
Caching headers only apply when the browser makes a request. A tab someone opened this morning already has v1's JavaScript in memory, and it will ask for v1's chunks no matter how you configure index.html. Keeping old assets around is the only thing that covers both cases.
Your CDN Reads The Same Header, With Different Rules
Everything so far has been about the browser, which RFC 9111 calls a private cache. Your CDN and any proxy in between are shared caches, and they read Cache-Control too - but a few directives exist just for them.
privatemeans shared caches must not store the response at all. Use it for anything personalised that browsers may still cache.publiclets shared caches store responses they otherwise would not, including responses to requests with anAuthorizationheader. It is not a "make it faster" flag. On an authenticated API response it can put one user's data in a cache that serves other users.s-maxageoverridesmax-agefor shared caches only.max-age=0, s-maxage=300means the browser always revalidates but the CDN keeps a copy for five minutes. That is a good fit for HTML you purge on deploy.CDN-Cache-Control(RFC 9213) is a separate header that only CDNs read, so you can give the edge a completely different policy from the browser. Support depends on your provider, so check their docs before relying on it.
The trap here is the CDN dashboard. Most CDNs let you set a "cache everything for N hours" rule that ignores or overrides origin headers. That rule plus a purge-on-deploy step works fine at the edge. But if the CDN also forwards a long max-age to browsers, your purge cleans the edge while every browser keeps its copy. Purges do not reach users' disks.
A CDN purge clears the edge. It does not clear a single browser. Only your headers reach that far.
So when you configure caching at the edge, check two things separately: what the CDN stores, and what Cache-Control it sends on to the browser. curl -sI against the production URL shows the second. It is the one people forget.
I Checked This Blog. It Is Not Following My Advice.
Fair is fair, so I ran the same check against the site you are reading this on. It is a Next.js static export on GitHub Pages.
$ curl -sI https://rgndunes.github.io/ | grep -i cache-control
cache-control: max-age=600
$ curl -sI https://rgndunes.github.io/_next/static/chunks/webpack-b8985194118577c8.js \
| grep -i cache-control
cache-control: max-age=600
Both get max-age=600, the HTML and the content-hashed chunk. GitHub Pages sets this for everything and does not let you configure headers. So for up to ten minutes after I publish, a returning reader can get the previous HTML. And a hashed file that will never change gets revalidated every ten minutes for no reason. The upside is that GitHub Pages sends ETags, so those revalidations are cheap 304s rather than full downloads.
For a personal blog, I am fine with that trade. It is free, it deploys on push, and ten minutes of staleness on an article page hurts nobody. But I would never run a product frontend this way. If a payments page or a dashboard ships a fix, "most users will get it within ten minutes" is not good enough, and paying for a revalidation on every hashed chunk is wasted latency on every visit after the first ten minutes.
That is the honest version of this advice. You do not always control headers. Some hosts pick them for you. The point is to know which policy you are running and choose the host with that in mind, rather than finding out from a support ticket.
curl -sI against your production index.html and one hashed asset. If the HTML has no Cache-Control, or the hashed file has a short one, you have found your next infra ticket.Headers I Keep Seeing Copy-Pasted
Caching config tends to get written once, copied from a Stack Overflow answer, and never read again. These are the ones I see most often, and what they actually do.
no-cache, no-store, must-revalidate.no-storealone already means "do not keep a copy". The other two describe how to treat a stored copy that will never exist. Harmless, but it tells you nobody read the spec.must-revalidateon its own. It does not force a check on every request. It only says that once the response is stale, the cache must not serve it without revalidating. If it is fresh, it is used as normal.Pragma: no-cache. An HTTP/1.0 request header. RFC 9111 deprecatesPragma, and it never had a defined meaning in responses. It does nothing for your caching policy.Expiresnext tomax-age. When both are present,max-agewins andExpiresis ignored. Keeping both is fine; tuning the wrong one is not.stale-while-revalidate(RFC 5861). Serve the stale copy now and refresh in the background. Very useful on a CDN. Browser support is not universal, so do not build your correctness story on it.no-storeon every page "to be safe". It costs the full download on every visit and, as my test showed, on every Back press too. Keep it for responses that genuinely must not stay on disk.
For every Cache-Control value in your repo, try to explain in one sentence why each directive is there. The ones you cannot explain are the ones to delete or fix.
Stop Letting The Browser Guess
If you keep one thing from this, keep the short version:
- No
Cache-Controldoes not mean no caching. With aLast-Modifiedheader, Chromium caches for about 10% of the file's age, and I watched it serve v1 after a v2 deploy without sending a request. no-cachemeans "store, but revalidate before every use".no-storeis the one that means "do not cache".- HTML gets
no-cacheplus an ETag. Content-hashed assets getmax-age=31536000, immutable. Anything without a hash in the name gets neither of those extremes. - Reload in Chromium revalidates the page, not its fresh subresources. The Back button reuses the stored copy even for
no-cache. - Keep previous releases' hashed assets on the origin. Stale HTML and already-open tabs will ask for them.
- A CDN purge does not reach browsers. Check what your edge forwards, not just what it stores.
None of this is new. RFC 9111 was published in 2022 and heuristic freshness goes back decades before that. What keeps catching teams out is the naming - no-cache sounds like the off switch, and no header sounds like no caching. Neither is true, and the cost shows up as your least reproducible bug.
Caching is going to happen whatever you do. The only choice you have is whether you wrote the policy or the browser guessed it.
pageshow's persisted flag. Directive semantics, heuristic freshness (Section 4.2.2), the shared-cache rules for Authorization and the history-list behaviour come from RFC 9111; immutable from RFC 8246; stale-while-revalidate from RFC 5861; CDN-Cache-Control from RFC 9213. GitHub Pages headers were checked with curl -sI on 24 September 2026. Firefox and Safari make different choices where the spec allows it, so verify there before relying on exact behaviour.Related essays
- Vite · Frontend Infrastructure
We Blamed Vite For A 90-Second Local Load. The Real Bottleneck Was The 11,516 Modules We Asked It For.
A local Vite page took 90 seconds to load because eager route imports pulled 11,516 modules into one page load. Making routes lazy cut it to 54 modules and 10.54 seconds. The mechanism, how to count your own modules, and where the fix stops working.
- JavaScript · Browser Internals
setTimeout(fn, 0) Is Not Zero. In A Background Tab, Your 100ms Timer Runs Once A Minute.
The delay you pass to setTimeout is a minimum. Measured in Chromium, Firefox, WebKit and Node: the 4ms nesting clamp, hidden tabs throttled to 1 second, Chrome's once-a-minute intensive throttling after 5 minutes, a 30-day timer that fires instantly, and what to use instead.
- Browser Internals · Performance
Chrome Stopped Running Your unload Handler. If Your Analytics Still Use It, Your Numbers Already Changed.
Since Chrome 154, unload handlers are off by default on every Chrome page load - they register without error and never run. Reproduced in Chromium: what it does to the Back button, which data quietly went missing during the seven-month rollout, and the pagehide pattern to use instead.