DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on Originally published at devya.dev

Why My CDN Cache Never Hit: Field Notes on Cache-Control, s-maxage, and stale-while-revalidate

Headline: 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 my hit rate 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 my origin.

I had a public product endpoint that returned identical JSON for every visitor and still reached my origin on every request. The CDN configuration was fine. The response was the problem, and the header that gave it away took about thirty seconds to read once I stopped looking at dashboards.

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 my 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 Cache-Status, so newer intermediaries expose it under one name.

MISS and BYPASS mean different things, and that distinction was 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.

curl -sI https://example.com/api/products | grep -iE 'cache|age|vary|set-cookie'
Enter fullscreen mode Exit fullscreen mode

The Age response header is the confirmation: 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. 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. If you want a response never written to disk anywhere, no-store is the one you mean.

Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
Enter fullscreen mode Exit fullscreen mode

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

Header Read by Precedence
Vercel-CDN-Cache-Control Vercel Edge Network only Highest; not forwarded to the browser
CDN-Cache-Control Any CDN implementing RFC 9213 Middle; browsers ignore it
Cache-Control Browsers, proxies, and CDNs Fallback when no targeted header is present

My rule after this incident: 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 I deploy or purge, while an edge cache with an hour of TTL can be invalidated the moment the data changes.

export const dynamic = 'force-static';

export async function GET() {
  const products = await getProducts();
  return Response.json(products, {
    headers: {
      'CDN-Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
      'Cache-Control': 'public, max-age=0, must-revalidate',
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

What silently disables CDN caching?

Five response-level conditions account for nearly every mystery bypass I have chased.

  • Set-Cookie on the response. 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. My 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: *. 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. 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. 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. Any request-time API that marks a route dynamic will emit it, and nothing downstream can override a no-store you did not notice sending.

How do I 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('products') called from a Server Action or a route handler invalidates every fetch tagged with next: { tags: ['products'] }, 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; opt back in with export const dynamic = 'force-static'. fetch() no longer defaults to force-cache; request caching 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 the explicit header pair above is worth writing rather 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 I test CDN cache headers 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: Do I still need ETag if I set s-maxage?
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.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)