no-cache does not mean "don’t cache"
The name is a forty-year-old mistake and it costs people real money. Cache-Control: no-cache means a cache may store the response, but must revalidate it with the origin server before reusing it. The response sits on disk, the browser sends a conditional request, and in the common case the server answers 304 Not Modified with an empty body. You still pay a round trip. You do not pay for the payload.
no-store is the actual off switch. Nothing may be written anywhere: not the browser disk cache, not the CDN edge, not the proxy in the office network. RFC 9111 is explicit that this covers the response headers as well, not only the body.
Then there is must-revalidate, which does nothing at all while a response is fresh. It only takes effect once the response is stale, and it forbids the cache from serving that stale copy when the origin cannot be reached. Which is why the cargo-culted no-cache, no-store, must-revalidate triple is two thirds redundant: no-store already ended the conversation.
| Directive | May a cache store it? | May it be reused without asking? |
|---|---|---|
no-cache | yes | no, revalidate every time |
no-store | no | nothing to reuse |
max-age=600 | yes | yes, for 600 seconds |
must-revalidate | yes | yes while fresh, never once stale |
One footnote for anyone who grew up on the old spec: RFC 9111, published June 2022, obsoletes RFC 7234 and removed the Warning header field entirely. If your logging still looks for "110 Response is Stale", it is looking for something no modern cache emits.
The heuristic trap: caches guess when you say nothing
This is the one that surprises even experienced people. If a response has no Cache-Control and no Expires, it is not uncacheable. RFC 9111 lets a cache invent a freshness lifetime, and the algorithm it explicitly encourages is a fraction of the time since Last-Modified, with 10% named in the spec as the typical setting.
Work through what that means. A PDF that was last modified 300 days ago gets served with no caching headers. A cache is within its rights to consider it fresh for 30 days. You update the file, and a chunk of your users keep the old one for a month, with no bug on your side to find because you never configured any caching in the first place. We have watched this eat an afternoon on a "the download link is wrong" ticket where the link was right the whole time.
The same mechanism applies to responses you would not think of as cacheable at all. RFC 9111 lists status codes that caches may store heuristically, and 404, 405, 410 and 301 are on it. A permanent redirect with no cache headers is the classic disaster: browsers cache 301s aggressively and the user has no obvious way to clear one, which is a big part of why we tell people to reach for a 302 or 307 whenever the change might not be permanent (the full argument is in 301 vs 302 redirects).
Practical takeaway: send an explicit Cache-Control on everything. "No header" is a decision, just not yours.
max-age vs s-maxage, and who listens to which
max-age applies to every cache. s-maxage applies only to shared caches, meaning CDNs, reverse proxies and gateways, and it overrides max-age for them while browsers ignore it completely. That split is more useful than it looks, because it lets you keep a document permanently fresh for users while still absorbing traffic at the edge.
The pattern for server-rendered pages that look the same for everyone: Cache-Control: public, max-age=0, s-maxage=86400. Every browser revalidates, the CDN answers almost every request from cache, and a purge on deploy pushes the new version out globally. Vercel documents essentially this as its default recommendation, and Cloudflare, Fastly and CloudFront all honour s-maxage the same way.
The companion directive is private, which tells shared caches to keep their hands off while letting the browser store the response. It is scoping, not security. A private response is still written to disk on the user’s machine in plain text.
Hashed filenames plus immutable, the modern pattern
The strategy that made cache invalidation a solved problem for static assets: put a content hash in the filename (app.4f3a9c1e.js) and cache it forever. New content means a new URL, so there is nothing to invalidate, ever. Old and new versions coexist, which matters more than people expect during a rolling deploy where a user might load an HTML document from one server and its assets from another.
The header for those files is Cache-Control: public, max-age=31536000, immutable. One year is the conventional ceiling. immutable comes from RFC 8246 (September 2017) and says the resource will not change during its freshness lifetime, so a cache never needs to revalidate, not even when the user hits reload. That last bit is the entire point: without it, a plain browser reload fires off conditional requests for every asset on the page and you get a wall of 304s for files that could not possibly have changed.
Support is uneven in a way worth knowing. Firefox implemented immutable in Firefox 49 and honours it on reload. Chrome never implemented RFC 8246; it solved the same problem differently by not revalidating subresources on a normal reload at all. So the directive costs you nothing and helps in Firefox and Safari. Send it.
The build-side half of this pairs naturally with minification: the same bundler step that hashes the filename is the one that shrinks the file, and if you want to check what a given bundle looks like minified before wiring it into a pipeline, our JavaScript minifier runs entirely in the browser and shows the gzip size of the output, which is the number your Content-Length will actually reflect.
What breaks the pattern: caching the HTML with the same aggressiveness. The document is the thing that names the hashed assets, so it must be revalidated or nothing new ever reaches anyone. Long-cache the assets, never the document.
stale-while-revalidate, and its underused sibling
stale-while-revalidate=600 lets a cache serve an expired response straight away and fetch a fresh one in the background. The user gets a cache hit with zero latency, the next user gets the updated copy. Defined in RFC 5861 back in 2010, supported in Chrome since 75 and Firefox since 68, and silently ignored by anything older, which degrades to plain max-age behaviour.
The typical shape is max-age=60, stale-while-revalidate=600: content is never more than eleven minutes old in the worst case, but nobody ever waits for a revalidation. Every major CDN supports it at the edge too, where it matters more than in the browser.
RFC 5861 defines a second extension almost nobody uses, stale-if-error. It lets a cache keep serving the stale copy when the origin returns a 5xx or times out. If your marketing site should stay up while the backend is being redeployed, that is one header.
ETag vs Last-Modified, weak vs strong
Both are validators: tokens the client echoes back so the server can answer 304 Not Modified instead of resending the body. Last-Modified comes back as If-Modified-Since, ETag as If-None-Match. Send both if you have both; when a server has an ETag, RFC 9111 says it should prefer it for the comparison.
The important limitation of Last-Modified is resolution. HTTP dates have one-second granularity, so a file written twice within the same second is indistinguishable, and the client keeps a stale copy with no way to notice. On a busy deploy pipeline that is not a theoretical race.
ETags come in two flavours. A strong ETag ("33a64df5") promises byte-for-byte identity. A weak one (W/"33a64df5") promises only semantic equivalence, which is the honest choice when the same resource may be served gzipped or brotli-compressed. The practical difference: only a strong validator may be used with If-Range, so weak ETags break resumable and partial downloads. If you serve large media and range requests behave strangely, check whether something in the chain weakened your ETags.
The ETag traps in Apache and nginx
Apache’s historical default for FileETag was INode MTime Size. The inode number is a filesystem detail, unique per server, so the identical file deployed to four machines behind a load balancer produced four different ETags. A client validating against server 2 with the ETag it got from server 1 never matched, so every conditional request came back as a full 200 instead of a 304. Steve Souders documented this in "High Performance Web Sites" in 2007 as one of the original Yahoo performance rules, and it was widespread enough that Apache changed the default to MTime Size in version 2.4. If you still run 2.2-era config carried forward, check it.
Related: Apache’s mod_deflate appends -gzip to the ETag of compressed responses, which historically caused conditional requests to miss when the same resource was requested with different encodings. Modern versions weaken the ETag instead of mangling it, but a stale DeflateAlterETag setting can still bring the old behaviour back.
nginx has a gentler version of the problem: its ETag is built from the modification time and content length, which is stable across servers as long as your deploy preserves mtimes. Many deploy methods do not. A fresh git clone or an untarred artifact with new timestamps on every host produces the same mismatch, and the symptom is identical, zero 304s and a mysteriously high bandwidth bill.
Vary, the cache-key footgun
Vary tells caches which request headers are part of the cache key. Get it wrong in one direction and users are served each other’s content. Get it wrong in the other and your hit rate collapses.
- Missing
Vary: Accept-Encodingon compressed responses is the classic corruption bug: a proxy stores the gzipped body and hands it to a client that never asked for gzip. Every modern server sets this automatically, and every hand-rolled compression middleware forgets it. Vary: User-Agentgives every browser build its own cache entry. There are tens of thousands of UA strings in the wild, so this is a polite way of disabling shared caching.Vary: Cookiemeans one cache entry per unique cookie set, which is one per user. Occasionally correct, usually accidental, and the reason a CDN hit rate can sit at 2% for no visible reason.Vary: *makes every request unique, so in practice it isno-storewith extra steps.- Forgetting
Vary: Originwhen you echo an origin into anAccess-Control-Allow-Originheader produces CORS failures that depend on which visitor warmed the cache first. Genuinely miserable to reproduce, and covered in more detail in CORS errors explained.
Headers we actually ship
| Response | Cache-Control |
|---|---|
| Hashed JS, CSS, fonts | public, max-age=31536000, immutable |
| HTML documents | no-cache plus an ETag |
| Public API responses (CDN in front) | public, max-age=0, s-maxage=300, stale-while-revalidate=600 |
| Anything user-specific | private, no-cache |
| Statements, invoices, one-time tokens | no-store |
| Unhashed images and PDFs you may replace | public, max-age=3600 |
Most of the remaining pain comes down to two things. First, verify with curl -I against the real production URL rather than trusting your config, because CDNs, load balancers and framework middleware all rewrite these headers and the last writer wins. Second, when you debug in the browser, disable the cache in devtools while you work but always test the fix with the cache on, since a hard reload sends Cache-Control: no-cache on the request and hides exactly the behaviour you are trying to observe.
Caching questions that come up in review
What is the difference between no-cache and no-store?
no-cache allows a cache to store the response but forbids reusing it without revalidating with the origin server first, so you still get 304 responses and the bandwidth saving. no-store is the real off switch: nothing may be written to any cache, including the browser disk cache, CDN edges and corporate proxies. If you want a page never to be persisted, for example a bank statement on a shared machine, no-store is the directive you want. If you want a page always fresh but cheap to check, no-cache is.
What does Cache-Control: max-age=0 mean?
max-age=0 means the response is stale the instant it arrives, so a cache must revalidate before reusing it. That makes it near identical to no-cache in practice, with one difference: a stale response can still be served by a cache that is allowed to serve stale content, unless you add must-revalidate. The combination max-age=0, s-maxage=86400 is a useful pattern that tells browsers to always check while letting a CDN hold the response for a day.
Should I use ETag or Last-Modified?
Use ETag when you can compute a content hash cheaply, and Last-Modified when you cannot. ETag is exact, Last-Modified has one-second resolution, so two writes within the same second are invisible to it and clients keep a stale copy. Sending both is fine and normal: browsers echo them back as If-None-Match and If-Modified-Since, and per RFC 9111 a server that has an ETag should prefer it when deciding whether to answer 304 Not Modified.
Why is my CSS still cached after I deploy?
Because the browser was told it could keep it and has no reason to ask you again. If you shipped a long max-age on a file whose name never changes, there is no way to recall it: the cached copy stays until it expires. The fix is not a shorter max-age, it is a new URL for new content, so a content hash in the filename. Query strings like style.css?v=3 mostly work but are handled inconsistently by older proxies and some CDN configurations, and they do not let two versions coexist during a rolling deploy.
What is a good Cache-Control header for static assets?
For files whose name contains a content hash, Cache-Control: public, max-age=31536000, immutable. One year is the conventional ceiling, and immutable tells Firefox not to revalidate even when the user hits reload. For HTML, which must be able to point at the new asset names, no-cache (or max-age=0) plus an ETag so the common case is a cheap 304. The rule underneath: cache the things you can rename forever, never cache the document that names them.
Does Cache-Control: private mean the response is secure?
No. private only tells shared caches (CDNs, reverse proxies, corporate gateways) not to store the response; the user’s own browser still writes it to disk, and anyone with access to that machine or profile can read it. It is a cache-scoping directive, not a security control. For responses that must not be written to disk at all, use no-store, and remember that neither directive does anything about intermediaries that ignore the rules.
What does stale-while-revalidate do?
stale-while-revalidate lets a cache serve an expired response immediately while it fetches a fresh copy in the background, so users never wait for the revalidation round trip. It is defined in RFC 5861 from 2010 and has been supported in Chrome since version 75 and Firefox since 68; browsers that do not know it ignore the token and fall back to max-age. A typical setting is max-age=60, stale-while-revalidate=600, which caps how stale a response can get while removing the latency spike at the minute mark.
How do I stop a browser from caching an HTML page?
Send Cache-Control: no-store on the response. That is the whole answer for the browser; the meta http-equiv cache tags in the HTML head are ignored by real caches and always have been. If the page is behind a CDN, add private so shared caches skip it too. And check what you are not sending: with no Cache-Control and no Expires at all, caches are allowed to guess a freshness lifetime from the Last-Modified date, which is how uncached-looking pages end up cached anyway.