Skip to main content
Why a CDN Cache Never Hits: Our Field Notes on Cache-Control, s-maxage, and stale-while-revalidate

Why a CDN Cache Never Hits: Our Field Notes on Cache-Control, s-maxage, and stale-while-revalidate

September 14, 2026
Full-Stack Engineering
8 min read

A CDN that reports a miss on every request is almost never misconfigured at the CDN — the response itself is telling it not to cache. The three changes that fixed the hit rate on our public endpoint were removing an accidental Set-Cookie from a public GET response, splitting one Cache-Control header into a CDN-Cache-Control plus Cache-Control pair so the edge and the browser could hold different lifetimes, and adding stale-while-revalidate so expiry stopped meaning a round trip to the origin.

Key takeaways

Read the response headers before the CDN dashboard: x-vercel-cache on Vercel, cf-cache-status on Cloudflare, and the standard Cache-Status header defined in RFC 9211 each report why a request missed.

s-maxage is honoured only by shared caches such as a CDN, and browsers ignore it. max-age applies to every cache including the browser.

stale-while-revalidate=N, defined in RFC 5861, lets a cache serve an expired copy for N more seconds while it refreshes in the background, so the first visitor after expiry does not pay origin latency.

A response carrying Set-Cookie is not stored by most shared caches, and a single session-touching helper on a public route is enough to drop the hit rate to zero.

Long shared-cache lifetimes plus explicit purge beat short guessed TTLs: revalidateTag in Next.js and surrogate-key purges on other CDNs invalidate on write instead of on a timer.

Why does a CDN report a miss on every request?

Because the response is not cacheable, in almost every case. Vercel's Edge Network reports its decision in the x-vercel-cache response header, whose values are HIT, MISS, STALE, BYPASS, and PRERENDER. Cloudflare reports the same class of decision in cf-cache-status. RFC 9211 standardises this as the Cache-Status header, so newer intermediaries expose it under one name.

MISS and BYPASS mean different things, and that distinction is the whole debugging session. MISS means the cache looked, found nothing, and stored the response for next time — two identical requests in a row should produce a miss and then a hit. BYPASS means the cache refused to store the response at all, so the second request misses again, and the third, forever.

One command settles it: curl -sI against the URL, filtered for the cache, age, vary and set-cookie headers. The Age response header is the confirmation, because it reports how many seconds the shared cache has held this copy. An Age that is present and climbing across requests is a real hit, and an Age that never appears means nothing is being stored.

What is the difference between max-age, s-maxage, and stale-while-revalidate?

max-age=N marks a response fresh for N seconds in every cache, including the browser's private cache. s-maxage=N overrides max-age in shared caches only — a CDN or a proxy — and browsers ignore it entirely. That asymmetry is the useful part: a browser cache cannot be purged, and a CDN cache can.

stale-while-revalidate=N, from RFC 5861, is a grace window after freshness ends. For N seconds past expiry the cache may serve the stale copy immediately and refresh it in the background, which means expiry costs one background fetch rather than one slow user request. stale-if-error=N is its sibling: it lets the cache serve the stale copy when the origin returns a 5xx or is unreachable.

Two directives are constantly confused. no-cache allows the cache to store the response but forces revalidation before every reuse. no-store forbids storing it at all. For a response that must never be written to disk anywhere, no-store is the correct directive.

The header we settled on for public JSON is: public, max-age=0, s-maxage=300, stale-while-revalidate=86400. The browser revalidates every time, the edge treats the copy as fresh for five minutes, and for a day after that the edge answers instantly from stale while it refreshes behind the request.

When should we use CDN-Cache-Control instead of Cache-Control?

Use a targeted header whenever the browser and the CDN need different lifetimes. RFC 9213 defines targeted cache-control headers, where a cache obeys the most specific header addressed to it and falls back to Cache-Control when no targeted header exists.

Vercel-CDN-Cache-Control is read by the Vercel Edge Network only, takes the highest precedence, and is not forwarded to the browser.

CDN-Cache-Control is read by any CDN implementing RFC 9213, sits in the middle of the precedence order, and is ignored by browsers.

Cache-Control is read by browsers, proxies and CDNs alike, and acts as the fallback when no targeted header is present.

Our rule after this incident is to keep the browser lifetime at or near zero and push the real lifetime into the shared cache. A browser that cached a response for an hour will serve that hour no matter what we deploy or purge, while an edge cache with an hour of TTL can be invalidated the moment the data changes.

What silently disables CDN caching?

Set-Cookie on the response is the first killer. RFC 9111 instructs shared caches not to store that field, and CDNs including Vercel's Edge Network respond by refusing to cache the response at all. Our bug was exactly this: a shared auth helper ran on a public route, touched the session, and attached a rotating cookie to a response no visitor needed personalised.

Vary: Cookie or Vary: * is the second. The Vary header adds the listed request headers to the cache key, so varying on Cookie gives every distinct cookie string its own entry and the hit rate collapses toward zero.

An Authorization request header is the third. RFC 9111 section 3.5 blocks shared caches from reusing a response to an authorized request unless that response explicitly carries public, s-maxage, or must-revalidate.

A missing Cache-Control header is the fourth. Most CDNs treat an unlabelled dynamic response as uncacheable rather than guessing a heuristic lifetime, so silence is not a default TTL.

A framework default of no-store is the fifth. Any request-time API that marks a route dynamic will emit it, and nothing downstream can override a no-store that nobody noticed sending.

How do we invalidate cached responses instead of guessing a TTL?

Tag the content and purge on write. A TTL is a guess about how long data stays correct; a purge is a fact about when it changed. In Next.js, revalidateTag called with a tag such as products, from a Server Action or a route handler, invalidates every fetch tagged with that same value, and revalidatePath does the same for a route. On other CDNs the equivalent is a surrogate key — Fastly's Surrogate-Key response header plus its purge API, or cache tags on Cloudflare Enterprise.

The shape that works is a long shared-cache lifetime, a stable tag per entity, and a purge in the same code path that writes the entity. It converts cache correctness from a timing question into a deployment question.

What changed in Next.js 15 for route handlers and fetch caching?

Next.js 15 made caching opt-in where Next.js 14 made it opt-out, and a stale mental model from 14 causes real bugs. GET Route Handlers are no longer cached by default, and the opt-in is export const dynamic = 'force-static'. The fetch function no longer defaults to force-cache, so caching is requested per call with cache: 'force-cache' or next: { revalidate: N }. The client-side Router Cache also stops reusing page segments by default, tunable through the staleTimes config in next.config.

When a route handler sets Cache-Control itself on Vercel, that header is what the Edge Network follows, which is why writing the explicit header pair is safer than relying on a framework default that has already changed once.

FAQ

Q: Does s-maxage control the browser cache?

A: No. s-maxage is honoured only by shared caches such as CDNs and proxies. Browsers use max-age, so a response carrying only s-maxage is revalidated by the browser on every navigation.

Q: What is the difference between no-cache and no-store?

A: no-cache permits storage but requires revalidation with the origin before each reuse. no-store forbids storing the response anywhere. Use no-store for anything sensitive.

Q: Is stale-while-revalidate safe for HTML pages?

A: It is safe for shared, non-personalised HTML, where serving a slightly old page during the grace window is acceptable. It is not safe for per-user pages, which should be marked private or no-store.

Q: Can CDN cache headers be tested on localhost?

A: No, because there is no shared cache in front of a local dev server. Deploy a preview, curl the URL twice, and confirm the second response reports a hit with a non-zero Age.

Q: Is ETag still needed when s-maxage is set?

A: ETag saves bandwidth by allowing a 304 response, but a 304 still costs a round trip and usually origin work. For shared caches, s-maxage with stale-while-revalidate removes more latency than revalidation does.