Most "why is the old version still showing" bugs trace back to a cache header nobody set deliberately, or two caching layers disagreeing about who's in charge. Browsers, CDNs, and reverse proxies each maintain their own cache with their own rules, and Cache-Control is the one header that's supposed to govern all of them — but only if you use its directives correctly.

The core directives

text
Cache-Control: public, max-age=31536000, immutable

max-age sets freshness lifetime in seconds; public allows any cache, including shared CDN caches, to store the response; immutable tells the browser not to even revalidate on refresh, which matters because browsers otherwise issue a conditional request on hard refresh regardless of max-age. This combination is the standard for content-hashed static assets — app.a3f9c1.js — where a new deploy produces a new filename, so caching forever is safe by construction.

For anything that can change without a URL change — an HTML document, an API response — max-age=0 alone isn't enough on its own:

text
Cache-Control: no-cache

no-cache is misleadingly named — it doesn't forbid caching, it forbids using the cached copy without revalidating first. no-store is the directive that actually prevents storage entirely, appropriate for responses containing sensitive per-user data.

ETags: revalidation without re-downloading

An ETag is a fingerprint of the response body. On a subsequent request, the browser sends it back in If-None-Match, and the server responds 304 Not Modified with no body if the fingerprint still matches:

text
Request:  If-None-Match: "33a64df551"
Response: HTTP/1.1 304 Not Modified

This is what no-cache is actually enabling — the cached copy stays around, but every use gets a cheap round trip to confirm it's still current before serving it, instead of a full re-download. Pair it with Last-Modified as a fallback for servers or proxies that strip ETags, since some CDN configurations do.

Where the CDN layer disagrees with the browser

A CDN edge node has its own cache, governed by the same Cache-Control header by default, but it can be overridden independently:

text
Cache-Control: public, max-age=60, s-maxage=86400

s-maxage applies only to shared caches — the CDN edge — and takes precedence over max-age there, while browsers ignore it and use max-age. This split lets you serve a fast, edge-cached response globally for a day while still forcing each individual browser to revalidate every minute, which is a common pattern for content that changes occasionally but where a slightly stale browser cache is more tolerable than an origin hit on every edge miss.

The purge gap

Even with s-maxage set correctly, updating content before its TTL expires requires an explicit CDN purge — most providers expose this via API or dashboard. Teams that skip building purge into their deploy pipeline end up either setting max-age far lower than the content actually needs, sacrificing cache-hit ratio to compensate, or shipping updates that silently don't appear for existing edge caches until the TTL naturally expires. If your deploy process changes content at a stable URL, wiring a purge call into that same pipeline is the fix — not shortening the TTL until the problem becomes invisible rather than solved.

Vary: the header people forget

If a response differs by Accept-Encoding, Accept-Language, or an auth-dependent header, Vary tells caches to key on that header too — omitting it is how a CDN ends up serving one user's gzip-negotiated or localized response to someone else entirely.