Engineering Diaries · HTTP Caching
§
HTTP CachingFrontend InfrastructurePerformance

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.

01 · The Deploy That Did Not Arrive

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-Control does not mean "do not cache". It means "browser, you work out how long to cache this".
  • Cache-Control: no-cache does 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.

02 · Heuristic Freshness

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:

heuristic freshness
# 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.

Why this is legal

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.

03 · The Three Directives

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.

DirectiveStored?Reused without asking?What it actually means
no-storeNoNoDo not keep any copy. Download the whole thing every time.
no-cacheYesNoKeep a copy, but revalidate with the server before every use.
max-age=0YesNo (stale immediately)Fresh for zero seconds. Revalidate before reuse, with some edge cases.
(nothing)YesYes, for a guessed periodHeuristic 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.

The one-line versionno-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.
04 · What I Measured

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.

0
Requests on revisit, no header
2
304s on revisit, no-cache
2
Full 200s on Back, no-store
1
Request on reload, 1-year max-age
PolicyRevisit (normal navigation)ReloadBack button
No headerNothing sentHTML revalidated, script notNothing sent
no-cacheHTML + script revalidated (304)Both revalidatedNothing sent
no-storeBoth fully downloadedBoth fully downloadedBoth fully downloaded
max-age=0Both revalidated (304)Both revalidatedNothing sent
max-age=600Nothing sentHTML revalidated, script notNothing sent
max-age=31536000, immutableNothing sentHTML revalidated, script notNothing 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.

Scope of these numbers

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".

05 · The Deploy Test

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 HTMLNext visit after deployAfter reload
No header (Last-Modified 10 days old)v1v2
max-age=600v1v2
no-cache + ETagv2v2

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.

06 · The Two-Rule Setup

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:

Two Rules For A Static Frontend
index.html
no-cache + ETag
304 when unchanged
app-4f9a1c.js
1 year + immutable
never refetched

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:

nginx.conf
# 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.webmanifest and anything in a public/ folder keep their names across releases. Give them a year and you cannot change them.
  • Keep ETags on the HTML. no-cache without a validator means every revalidation downloads the full body. nginx sends ETags for static files by default. Check yours does.
  • immutable is 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. The max-age does 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.js with no-cache costs nothing and removes a variable. I went through the full lifecycle in my previous piece on service workers.
07 · The Chunk That Is Not There

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.

what the user's browser is doing
// 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-cache on 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:preloadError event you can listen for, and a single guarded reload is a reasonable response.
Headers cannot fix open tabs

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.

08 · Shared Caches

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.

  • private means shared caches must not store the response at all. Use it for anything personalised that browsers may still cache.
  • public lets shared caches store responses they otherwise would not, including responses to requests with an Authorization header. 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-maxage overrides max-age for shared caches only. max-age=0, s-maxage=300 means 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.

09 · My Own Site

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.

terminal
$ 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.

Check yours in thirty secondsRun 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.
10 · Cargo Cult Directives

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-store alone 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-revalidate on 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 deprecates Pragma, and it never had a defined meaning in responses. It does nothing for your caching policy.
  • Expires next to max-age. When both are present, max-age wins and Expires is 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-store on 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.
A quick way to review your config

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.

11 · The Short Version

Stop Letting The Browser Guess

If you keep one thing from this, keep the short version:

  • No Cache-Control does not mean no caching. With a Last-Modified header, 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-cache means "store, but revalidate before every use". no-store is the one that means "do not cache".
  • HTML gets no-cache plus an ETag. Content-hashed assets get max-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.

The request counts and the deploy test were reproduced locally with a small Node 26 HTTP server driven by Playwright 1.59.1 against headless Chromium 147.0.7727.15. Each route sent an ETag and a Last-Modified date ten days in the past; the back/forward result was confirmed as an HTTP cache load rather than bfcache via 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.