DEV Community

nocache.dev
nocache.dev

Posted on

Your CDN says HIT. Here's what that doesn't tell you

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
  • DYNAMIC on Cloudflare means Cloudflare didn't consider the response eligible for caching at all. By default it doesn't cache HTML, regardless of your headers.
  • Age tells you how long the copy has been in cache. An Age close to your s-maxage is normal; an Age far 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 and Vary, 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):

  1. Does max-age vs s-maxage match what you want for browsers vs the edge?
  2. Is no-store used only where data is sensitive?
  3. Is there an ETag or Last-Modified, and does the origin answer 304?
  4. Is Vary limited to headers with few values?
  5. Does any response with Set-Cookie say public or have a long s-maxage?
  6. Do errors get cached?
  7. 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
Enter fullscreen mode Exit fullscreen mode

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)