
You ship a fix at nine in the morning. At nine fifteen a user says the bug is still there. You deploy again. They hard reload. Nothing changes, and the file on the server is definitely the new one.
Their browser never asked for it. That is not a browser bug and it is not a CDN outage. It is the HTTP cache doing precisely what the headers told it to do, which in most cases means nobody told it anything and it made a reasonable guess on your behalf.
Here is what that layer actually does between your fetch call and your server, and how to read the headers well enough to decide what happens.
The cache is a small map with a boring key
The HTTP cache is not a mysterious performance feature. It is a key-value store. The key is the request method plus the URL, extended by whatever request headers the response lists in Vary. The value is the entire response: status, headers, and body.
Only GET and HEAD go in by default. A POST response is not stored unless it carries explicit freshness information and a Content-Location, which almost nothing bothers with, so assume it is not.
Once something is stored, every later request asks two separate questions:
- Is the stored copy still fresh?
- If it is not, can I check whether it changed without downloading it again?
They are independent. A stale copy can still save you the whole download. A fresh copy can still be wrong, which is what you are looking at when a deploy does not show up for anyone who visited last week.
Almost every caching bug comes from collapsing those two questions into one.

The default is not "do not cache"
A response with no Cache-Control at all is not automatically excluded. HTTP is designed to cache, so a cache is allowed to store it and reuse it for a heuristic lifetime, and the common heuristic is around ten percent of the time since Last-Modified. A file that has not changed in a year can be treated as fresh for a month without anyone deciding that.
That mechanism explains the bug at the top of this post far more often than any directive does. Nothing in the response said "do not cache", so the cache made a reasonable guess, and the guess was measured in weeks.
The practical rule is boring. Give every response an explicit policy, even when that policy is no-cache. A project-wide default header plus three documented exceptions is easier to reason about than twenty routes that each set their own value, and a new endpoint inherits something sane instead of something accidental.
Static hosts and frameworks will often set that default for you, and it is usually wrong for at least one route. Check the headers, not the documentation.
Freshness is measured from the server, not from your visit
max-age=600 does not mean ten minutes in the browser. It means ten minutes from the moment the response was generated on the origin, and the response carries the receipt:
HTTP/1.1 200 OK
Date: Fri, 30 Oct 2026 09:00:00 GMT
Cache-Control: max-age=600, public
Age: 480
Age is how long this copy has already existed in a shared cache. A response that spent eight minutes in a CDN arrives with two minutes of freshness left, not ten. The browser subtracts it and moves on.
That is why a max-age on an API route does not give each user their own window. It gives the whole path one window, and a CDN may have eaten most of it before the browser sees anything.
Expires does the same job with an absolute timestamp. max-age wins when both are present, and you should not send Expires at all. Clock skew between two machines turns it into a coin flip, which is why it lost.
no-cache does not mean "do not cache"
This is the one that costs people an afternoon.
no-cache means: store it, but ask the server before every reuse. The server usually answers 304 Not Modified, so the body never travels twice. You keep the bandwidth saving and you get correctness.
no-store means: do not write it down at all. Every request is a full round trip and a full response.
The names read backwards from their meaning, and the specification has said so for years. If you want "always up to date", no-cache is the directive. If you want "never let this response touch a cache", no-store is the directive.
no-store also has a cost that rarely gets mentioned. It takes the page out of the back/forward cache, the browser's snapshot of an entire page that restores instantly when the user presses Back. So no-store on a document buys you very little and makes navigation slower.
And the part that surprises people: adding no-store today does not remove a response that was already stored. If a URL was cached yesterday with max-age=3600, changing the header does not evict that entry. The browser has no reason to make a request, so it never sees the new header. A cache header is a promise about the future, not a switch you can flip backwards.
The closest thing to a standard way of dropping what a browser already has is Clear-Site-Data: cache, and that is a blunt instrument.
The other half of this mistake is the kitchen sink:
Cache-Control: no-store, no-cache, max-age=0, must-revalidate, proxy-revalidate
That is five directives for one meaning: a museum piece from the era of proxies that ignored no-store. It behaves exactly like no-store, so delete four of them.
An ETag is a cheap question with a small answer
Every response can carry a validator: ETag, Last-Modified, or both. The browser stores it next to the body. When the stored copy goes stale, the browser sends a conditional request instead of a fresh one:
GET /index.html HTTP/1.1
If-None-Match: "a3f9c1e0"
If the representation still hashes to that tag, the answer is tiny:
HTTP/1.1 304 Not Modified
Cache-Control: no-cache
ETag: "a3f9c1e0"
No body. A few hundred bytes of headers, and the browser promotes the stored copy back to fresh and reuses it.
Notice that the 304 repeats Cache-Control and ETag. It has to. A validated response is freshened using the headers in the 304, so a 304 that forgets Cache-Control can leave the stored entry with a stale lifetime or none at all. If you hand-roll this, set those headers before you branch on the conditional.
An ETag is an opaque string. Derive it from a content hash, a row version, or a build id. Strong tags are quoted, and weak tags carry a W/ prefix and promise semantic equivalence instead of byte equality. Comparison during revalidation is weak either way, so a weak tag is fine for deciding between 200 and 304.
Last-Modified is the cheaper validator and the one that gets people. It has one-second resolution, which is exactly enough time for two deploys. Two writes inside the same second produce the same timestamp, the browser asks If-Modified-Since, the server answers 304, and the user keeps the old page while you blame the CDN. File timestamps also drift between machines. A hash of the body does not.
Send both when you can. If-None-Match wins when both conditional headers are present, and Last-Modified is still useful to crawlers and to anything that displays when a page changed.

The server side, all in one file
The whole mechanism fits in one file. This runs on Node with no dependencies:
// server.mjs, run with: node server.mjs
import { createServer } from 'node:http';
import { createHash } from 'node:crypto';
const BUILD = 'a1b2c3d4';
const html = `<!doctype html>
<html>
<head>
<link rel="stylesheet" href="/assets/app.${BUILD}.css" />
</head>
<body>
<h1>Cache demo</h1>
<p id="out"></p>
<script type="module" src="/assets/app.${BUILD}.js"></script>
</body>
</html>`;
const css = 'body { background: #14161a; color: #d7ff3e; font-family: system-ui; }';
const js = `fetch('/api/profile').then(r => r.text()).then(t => {
document.querySelector('#out').textContent = t;
});`;
const profile = JSON.stringify({ id: 42, name: 'Demo user' });
const etagOf = body => `"${createHash('sha1').update(body).digest('base64url')}"`;
function send(req, res, body, type, cacheControl) {
const etag = etagOf(body);
res.setHeader('Content-Type', type);
res.setHeader('Cache-Control', cacheControl);
res.setHeader('ETag', etag);
if (req.headers['if-none-match'] === etag) {
res.writeHead(304).end();
return;
}
res.writeHead(200).end(body);
}
createServer((req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
if (pathname === '/') {
// The document must be revalidated, never reused blindly.
return send(req, res, html, 'text/html; charset=utf-8', 'no-cache');
}
if (pathname.startsWith('/assets/')) {
// The URL changes with every build, so these bytes never change.
const isCss = pathname.endsWith('.css');
return send(req, res, isCss ? css : js, isCss ? 'text/css' : 'text/javascript',
'public, max-age=31536000, immutable');
}
if (pathname === '/api/profile') {
// Per user, and worth asking about every time.
return send(req, res, profile, 'application/json', 'no-cache, private');
}
res.writeHead(404).end('not found');
}).listen(3000, () => console.log('listening on http://localhost:3000'));
Run it, open the network panel, and reload. The document comes back as 304 and the two assets come back from the cache without a request at all. Then check a request that does go out:
curl -i http://localhost:3000/api/profile
The first call is a 200 with an ETag. Feed that tag back and the server answers 304 with no body, which is the entire trick, written out:
curl -i -H 'If-None-Match: "your-tag"' http://localhost:3000/api/profile
If you use Express, res.send already generates a weak ETag and answers conditional requests for you. The version above is what it does behind the curtain, and the mechanism still matters once a CDN sits in front of Express, because that layer stops being Express's problem.
Three policies cover almost every route
The directive list is long, but the decisions are short. Pick by what the URL means, not by how slow the endpoint feels.
Content-addressed assets. If the filename changes when the bytes change, the bytes never change under that name.
Cache-Control: public, max-age=31536000, immutable
immutable only matters while the response is fresh, and what it buys is the absence of conditional requests for a response that will never change. Browsers are entitled to revalidate subresources on a reload. immutable tells them not to bother, because the bytes at that URL are final.
Never put this header on anything whose URL survives a deploy.
HTML. The document is the one resource you cannot cache bust, because its URL is the one people type and the one your links point at. It must always be checked:
Cache-Control: no-cache
ETag: "9f2c41ab"
Together those give a 304 when the page did not change and a new document the moment you deploy. This single pair is why your users do not end up with last week's HTML pointing at this week's asset hashes.

API responses. Decide by who the response belongs to, not by how expensive the query is.
# public data, safe to share
Cache-Control: public, max-age=60, stale-while-revalidate=600
# per-user data, never in a shared cache
Cache-Control: private, no-cache
stale-while-revalidate lets a cache serve a slightly old copy and refresh in the background, which is where most of the perceived speedup on a slow endpoint actually comes from. Support is broad now: Chrome since 75, Firefox since 68, Safari since 14.
private is not decoration. A shared cache is allowed to store a response that carried cookies, because a cookie does not make a response private. private is the statement that does. Leave it off and a CDN can hand one user's profile page to another, and you will hear about it from the user first.
There is one more trap on authenticated routes. A response to a request with an Authorization header is not stored by a shared cache unless the response says public, s-maxage, or must-revalidate. So adding public to an authenticated endpoint to "help the CDN" does the opposite of helping.
When the bad header is already out there
Changing a header fixes the next response, not the ones already stored. If users are holding a document with max-age=86400, no edit you deploy reaches them for a day, because the request that would carry the new header never arrives.
Three options, in order of how much control they give you.
Rename the URL. A new asset filename, or a query string that changes on every deploy, is a new key, and the old entry stops mattering. This is why content hashing exists, and why it beats any purge API.
Purge the shared cache. If the stale copy lives in a CDN, the CDN has an API for exactly this, and it is the right tool when the bad entry sits in the edge layer rather than in a browser.
For the browser cache, Clear-Site-Data: cache on any response from your origin drops what that browser has stored for the site. It is heavy, it can log users out of anything that depends on cached state, and it is the only lever you have. Send it briefly, then take it out.
The lesson is not subtle. A lifetime you hand out is a promise you cannot take back, which is why documents get no-cache and assets get versions.
Vary is part of the key, and that is where CORS bugs hide
The key is the URL, unless the response says otherwise with Vary. Vary: Accept-Encoding stores the compressed and uncompressed variants separately. Vary: Origin gives each origin its own copy.
The classic bug is an API that answers CORS per origin and forgets the second one:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
If Vary is missing, or the CDN ignores it, the first origin to hit the endpoint gets its Access-Control-Allow-Origin stored and every other origin receives that value back. Their browser blocks the response, the error appears for some users and not others depending on who warmed the cache, and it moves when you redeploy. It never reproduces locally.
Two fixes, in order. If the endpoint does not need credentials, answer Access-Control-Allow-Origin: * and stop varying. If it does need credentials, * is not allowed anyway, so send Vary: Origin and confirm the CDN respects it. Vary: * is a valid way to say never store this, and it means exactly that.
Do not key on Cookie. The value space is enormous, so the hit rate collapses while the cache fills with near-identical copies. private is the tool for per-user responses. Vary: Cookie is how you build a cache that is slower than no cache.
What a CDN adds to the picture
Everything above describes one cache. In production there are usually two: the browser, and whatever sits between it and your origin. The second one follows the same rules and answers to partly different directives.
s-maxage exists for that layer. It overrides max-age for shared caches and is ignored by the browser, which makes this pair do something specific and useful:
Cache-Control: public, max-age=60, s-maxage=600
The browser revalidates every minute and the edge holds its copy for ten. Without s-maxage, one value has to serve both layers, and you end up choosing between a stale edge and a chatty browser.
The Age header tells you which layer answered. A response from the edge arrives with a non-zero Age that reflects its time there. One that came straight from the origin has none.
Then there is the part that confuses people most: the headers the browser receives may not be the ones your server sent. Most CDNs let you override Cache-Control at the edge, in a dashboard rule or a config file, and the override wins. So "I set no-cache in the handler" and "the browser received max-age=3600" can both be true, and the network panel only shows you the second one. When those two stories disagree, believe the response.
Some CDNs also support CDN-Cache-Control, standardized in RFC 9213, which targets the edge without touching what browsers see. It is the right tool when the edge wants a long lifetime and the browser needs a short one. Vendor headers such as Surrogate-Control do the same job for one vendor, and only that vendor.
One more edge behavior worth knowing is request collapse. When many requests for the same cold URL arrive at once, a shared cache is allowed to send a single request to the origin and fan that answer back out to everyone. It is a gift for a public endpoint and a leak for a personalized one, and it happens even when the response says no-cache for the browser. private is what stops it.
What JavaScript can and cannot see
The page barely knows any of this is happening. When you call fetch, the browser decides whether to touch the network, and a 304 is resolved before your code sees it. You get a 200 with the stored body. There is no 304 in JavaScript, so if you are waiting for one to confirm a cache hit, you are waiting for something that does not exist.
You can still steer it per request:
// Whatever the headers say.
await fetch('/api/profile');
// Ask the server, but accept a 304. This is what a normal reload does.
await fetch('/api/profile', { cache: 'no-cache' });
// Do not read the cache and do not write it. Nothing is stored.
await fetch('/api/profile', { cache: 'no-store' });
// Do not read it, but do store what comes back. This is a force reload.
await fetch('/api/profile', { cache: 'reload' });
// Take whatever is stored, even if it is stale. Only a miss hits the network.
await fetch('/api/profile', { cache: 'force-cache' });
The names are easy to confuse. no-cache means check with the server, which is the mode you want when you need the latest version but still want the 304. reload means skip the read but keep the write. force-cache is the one that bites after a logout, because it will happily return the previous user's response for the same URL.
Headers are a second problem. Only a handful of response headers are readable from cross-origin JavaScript. Cache-Control is on that list, and ETag and Age are not. If a client needs to show cache age or send a validator, the server has to opt in:
Access-Control-Expose-Headers: ETag, Age
On Angular there is one version detail worth knowing. In version 22 the fetch backend is the default HttpBackend, and withFetch() is deprecated because it no longer does anything. HttpClient still will not show you a 304. With observe: 'response' you get the stored 200 and its headers, which is the right abstraction for an app and the wrong one for debugging a cache problem. That is what the network panel is for.
A service worker is a cache you own
There is one layer above the HTTP cache that behaves differently, and it is the one people forget when a deploy goes wrong. A service worker intercepts fetch before the network is involved. Whatever it decides to return is what the page gets, and Cache-Control on that response has no say in it.
That is a lot of control, and it is also a second cache with a separate lifecycle. In Angular, @angular/service-worker downloads the new build in the background but keeps answering with the old shell until the worker activates it, which usually happens when the last tab of the app closes. A user can sit on last week's application while your server is perfectly healthy, and no header you change today reaches them.
Two things follow. If you ship a service worker, give it a versioned manifest and a way to check for updates, and do not expect a hard reload to rescue you, because a hard reload does not bypass a service worker. And when you debug a stale page, open Application, then Service Workers, before you spend an hour on cache headers.
Debugging without guessing
The status column is the fastest read. A 200 came from the network, unless the size column says (disk cache) or (memory cache), which means it never left the machine. A 304 went to the network as a question and came back as a list of headers. Safari labels both cached cases from cache.
The Age header is the first thing to open when you suspect a CDN. A non-zero Age means you are looking at a shared cache hit, and the number is how long that copy has existed. If Age is large while your max-age is small, your configuration never applied, and the layer you are arguing with is not the one you think it is.
Two habits worth building.
Uncheck "Disable cache" before you test caching. It only applies while DevTools is open, and it makes every request behave like a first visit, so you can never reproduce what the user sees. Test with the panel closed, or in a fresh profile.
And remember that curl is not the browser. curl -I shows what the origin sends, but it does not send Accept-Encoding: gzip unless you ask, so anything that varies on encoding takes a different path and may return a different body. For a real revalidation test, send the validators yourself:
curl -sI -H 'Accept-Encoding: gzip' -H 'If-None-Match: "a3f9c1e0"' https://example.com/
The uncomfortable part of all this is that once a response is stored with a long lifetime, the server loses control of that URL. No header you send later reaches a client with no reason to ask. That is the argument for short lifetimes on documents and long ones on assets, and for treating every max-age as a decision you cannot undo.
Did it actually help?
Caching is one of the few performance changes you can measure almost exactly, and one of the easiest to measure wrongly.
Do not measure from your own browser. You have a warm profile, a service worker from last week, and DevTools open, which is three differences from the user you are trying to help. Measure from a fresh profile, or measure on the server.
On the client, PerformanceResourceTiming reports what happened to a request. A cache hit looks like a transferSize of zero next to a decodedBodySize that is not, because the body exists and nothing traveled:
const [entry] = performance.getEntriesByName('/assets/app.a1b2c3d4.css');
console.log({ transferSize: entry.transferSize, decodedBodySize: entry.decodedBodySize });
The trap is that a zero transferSize also shows up for opaque cross-origin responses without Timing-Allow-Origin, where the numbers are hidden rather than empty. Check that decodedBodySize is populated before you call it a hit.
On the server the numbers are less ambiguous. Count requests per URL and count how many of them got a 304. A high 304 ratio on documents means revalidation is working. A low number of origin requests for an asset means the edge or the browser is doing its job. A page that still sends forty requests to your origin on every navigation has a policy problem, not a caching problem.
One number to ignore is cache hit rate on responses that vary per user. Pushing that up is not an optimization, it is how a shared cache ends up serving one person's data to another.
The short version
- Hashed assets:
public, max-age=31536000, immutable - Documents:
no-cacheplus anETag - Per-user API data:
private, no-cache - Public API data: a short
max-ageplusstale-while-revalidate Varyon anything that answers differently per origin- Never
no-storeas a stand-in for "make the problem go away"
The next time a deploy does not show up, start with the network panel, not the build. Look at the document's status, its Age, and the Cache-Control on the response that actually reached the browser. The answer is usually in those three fields, and it was decided months earlier by a default nobody set.