x-cache: HIT feels like a finished job. The edge answered, the origin rested, done.
But a HIT only tells you one thing: this particular edge node had a copy and served it. It says nothing about whether browsers will reuse that copy, whether the edge should have stored it at all, or how many copies of the same page the edge is keeping. Most caching bugs I've seen live in exactly those gaps.
Here are the ones worth checking.
1. Browsers and CDNs read different numbers
Cache-Control: public, max-age=60, s-maxage=3600
max-age applies to every cache. s-maxage applies only to shared caches (CDNs, proxies) and overrides max-age for them. So the line above means: the CDN keeps it for an hour, the browser for a minute.
That split is usually what you want for HTML: the edge absorbs traffic, while browsers come back soon enough to pick up a deploy. The pitfall is the reverse. A response with max-age=0, s-maxage=300 gets a HIT on every edge request and still makes every browser go to the network on every navigation. The CDN dashboard looks great. Your users' waterfalls do not.
Static assets with hashed filenames are the opposite case. They never change under the same URL, so tell everyone to keep them:
Cache-Control: public, max-age=31536000, immutable
immutable tells browsers not to revalidate even on a normal reload.
2. no-cache does not mean "don't cache"
This one trips up nearly everyone once.
-
no-cache: you may store it, but you must revalidate with the origin before every reuse. -
no-store: don't store it anywhere.
no-cache combined with a validator (next section) is often the right answer for HTML that changes unpredictably: the browser keeps a copy, asks "still good?", and gets a tiny 304 back most of the time. no-store throws all of that away and should be reserved for genuinely sensitive responses, like account pages or anything with personal data. It also tends to keep pages out of the browser's back/forward cache.
3. Revalidation is cheap, if you have a validator
When a cached copy goes stale, the cache can ask the origin whether it changed instead of downloading it again. It needs a validator to do that:
ETag: "5f3a9c"
Last-Modified: Tue, 20 Oct 2026 09:12:00 GMT
The next request carries If-None-Match: "5f3a9c" (or If-Modified-Since), and if nothing changed the origin answers 304 Not Modified with no body.
No validator means no 304s, so every expired copy is a full download. nginx sends ETag and Last-Modified for static files by default. Express sends weak ETags for res.send() bodies by default, but generating an ETag doesn't save you the work of rendering the page, only the bytes on the wire.
One subtle failure: multiple origin servers generating different ETags for the same file (inode-based ETags on older Apache setups were the classic case). Each server disagrees with the others and revalidation quietly turns into full downloads.
4. Vary can split your cache into thousands of pieces
Vary lists request headers that change the response, so the cache stores a separate copy per value.
Vary: Accept-Encoding is fine; there are only a few values. Vary: Cookie is where things go wrong. Every visitor's cookie header is different (analytics IDs, consent flags, A/B buckets), so every visitor gets their own cache entry and the hit ratio collapses. Some CDNs don't cache responses with Vary: Cookie at all.
Vary: User-Agent has the same problem with a smaller blast radius. If you need per-device responses, normalize the value at the edge into a short header like X-Device: mobile and vary on that instead.
Also look at Vary headers your framework adds. Next.js, for example, varies on its own router headers (rsc, next-router-state-tree and others). That's deliberate and fine, but it's worth knowing they're there before you add more.
5. Set-Cookie on a publicly cacheable response
This is the one that can leak data rather than just waste it.
If a response says public and also sets a cookie, a shared cache is allowed to store it. If the cookie is stored with it, the next visitor may receive the previous visitor's cookie. Most CDNs protect you here by default (Cloudflare won't cache a response with Set-Cookie unless told to), but "most" and "by default" are doing a lot of work, and a cache rule written for something else can turn that protection off.
A real example. Here's part of the response vercel.com's homepage returned when I checked it today:
cache-control: public, max-age=0, must-revalidate
x-vercel-cache: HIT
age: 490
set-cookie: _v-consent=…; Path=/; Domain=.vercel.com; Secure; SameSite=lax
set-cookie: _v-visitor-id=…; Path=/; Domain=.vercel.com; Secure; SameSite=lax
To be fair to Vercel: these look like cookies added per request on the way out, after the cache lookup, so this is very likely not a leak. Their edge knows what it's doing. But the headers on their own say "any shared cache may store this, and here's a visitor ID." A corporate proxy or a second CDN layered in front doesn't know about Vercel's internals; it only reads the headers.
The rule I follow: if a response sets a cookie, it is either private or the cookie is moved somewhere else (a separate endpoint, or client-side). nginx:
location /account/ {
add_header Cache-Control "private, no-cache" always;
}
Express:
app.use((req, res, next) => {
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) => {
if (name.toLowerCase() === 'set-cookie') {
setHeader('Cache-Control', 'private, no-cache');
}
return setHeader(name, value);
};
next();
});
That middleware is blunt, but it means nobody on the team can accidentally ship a cookie on a public response.
6. stale-while-revalidate hides latency, not mistakes
Cache-Control: public, s-maxage=300, stale-while-revalidate=86400
For five minutes the copy is fresh. After that, for up to a day, the cache serves the stale copy immediately and refreshes it in the background. Users almost never wait on the origin.
Two things to know. Support varies: browsers support it, and CDN support has improved a lot, but check yours. Some honor it only on certain plans or only from certain directives; others ignore it silently. And it extends the life of bad responses too. If an error page or a broken deploy gets cached, stale-while-revalidate can keep serving it while the background refresh keeps failing. Pair it with stale-if-error deliberately, and make sure 5xx responses are not cached with long lifetimes.
7. Reading the status header correctly
Each CDN reports cache status differently:
| CDN | Header | Values to know |
|---|---|---|
| Cloudflare | cf-cache-status |
HIT, MISS, EXPIRED, REVALIDATED, BYPASS, DYNAMIC
|
| Vercel | x-vercel-cache |
HIT, MISS, STALE, PRERENDER
|
| Fastly, CloudFront, Varnish | x-cache |
HIT, MISS (CloudFront: Hit from cloudfront) |
Things that confuse people:
- The first request to an edge location is usually a MISS. Run it twice before concluding anything. You may hit a different node each time.
-
DYNAMICon Cloudflare means Cloudflare didn't consider the response eligible for caching at all. By default it doesn't cache HTML, regardless of your headers. -
Agetells you how long the copy has been in cache. AnAgeclose to yours-maxageis normal; anAgefar beyond it means something is overriding your headers. -
A HIT for the CDN is not a HIT for the browser. The browser decision is made from
max-age, validators andVary, and none of it shows up in the CDN's status header.
A quick checklist
For each important URL (homepage, a typical HTML page, a JS bundle, an API response):
- Does
max-agevss-maxagematch what you want for browsers vs the edge? - Is
no-storeused only where data is sensitive? - Is there an
ETagorLast-Modified, and does the origin answer 304? - Is
Varylimited to headers with few values? - Does any response with
Set-Cookiesaypublicor have a longs-maxage? - Do errors get cached?
- Run it twice: what does the CDN status header say the second time?
curl -sI https://example.com will answer most of these. I got tired of doing it by hand, so I built nocache.dev: a free checker that makes the request, identifies the CDN, flags the issues above and suggests a fix for your server. The vercel.com example came from it. There's also a CLI:
npx nocache-dev check https://example.com
Add --min-grade B and it exits non-zero below that grade, so you can run it in CI. If you find a rule you think is wrong, the methodology page lists all of them, and I'd like to hear about it.
Top comments (0)