Your Cache-Control Header Is Probably Wrong

no-cache does not mean do not cache. max-age on a CDN is not what you think. Here is what each directive actually does.

Share
Your Cache-Control Header Is Probably Wrong. Abstract networking illustration in orange and dark grey on debugly.dev

The short answer

The four configurations that cover almost everything:

# hashed static assets: app.a1b2c3.js
Cache-Control: public, max-age=31536000, immutable

# HTML that must be fresh but can be served stale briefly
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300

# personalised or authenticated responses
Cache-Control: private, no-store

# API responses that can be revalidated
Cache-Control: private, no-cache
ETag: "abc123"

And the one everyone gets wrong: no-cache does not mean do not cache. It means cache it, but revalidate before every use. no-store is the one that means do not store it.

The directives that matter

max-age=N is how long any cache may reuse the response without asking, in seconds.

s-maxage=N overrides max-age for shared caches, meaning CDNs and proxies. This is the one that lets you cache aggressively at the edge while keeping browsers honest, and it is underused.

public means shared caches may store it. private means only the browser may.

no-cache stores the response and revalidates before each use. You still save bandwidth when nothing changed, because the server can answer 304.

no-store does not store it at all. This is what you want for anything sensitive.

must-revalidate means once stale, do not serve it, revalidate first. Without this, some caches serve stale content when they cannot reach the origin.

immutable tells the browser the content will never change at this URL, so do not revalidate even on a reload. Only correct for content addressed URLs.

stale-while-revalidate=N serves stale content for up to N seconds after expiry while refreshing in the background. The user gets an instant response and the cache updates behind them. This is one of the highest value directives and a lot of people have never used it.

The mistakes

Using no-cache when you mean no-store

Cache-Control: no-cache

on a page containing personal data. The response is stored, in the browser cache and on disk. Anybody with access to the machine can read it, and a shared computer leaks the previous user's data.

For anything authenticated:

Cache-Control: no-store

Caching HTML forever

Cache-Control: public, max-age=31536000

on index.html. Users who visited once will not see a new deploy until the cache expires or they hard refresh. There is no way to invalidate a browser cache you have already populated.

HTML should be revalidated or cached only at the CDN, where you control purging:

Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=600

Browsers always check. The CDN serves from cache for five minutes and serves stale for another ten while refreshing.

Not caching hashed assets

Cache-Control: public, max-age=3600

on app.a1b2c3d4.js. The filename contains a content hash, so the content at that URL can never change. Caching it for an hour means needless revalidation forever.

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

One year, never revalidate. When the content changes, the filename changes, so there is no invalidation problem.

immutable specifically prevents revalidation on reload, which browsers otherwise do. It is a meaningful difference for users who refresh.

Forgetting Vary

Cache-Control: public, max-age=300
Content-Encoding: gzip

with no Vary header. A CDN caches the gzipped version and serves it to a client that did not ask for gzip.

More dangerous with content negotiation:

Vary: Accept-Encoding, Accept-Language

And critically, if you return different content per user:

Vary: Authorization, Cookie

Though at that point, private, no-store is usually the honest answer, because varying on Cookie produces a cache entry per user and caches nothing effectively.

If you echo CORS origins dynamically, you must send Vary: Origin, or a CDN will cache one origin's Access-Control-Allow-Origin and serve it to another, producing an intermittent CORS failure that depends on cache state.

ETags and conditional requests

An ETag lets the client ask "has this changed?" and get a 304 with no body.

# response
ETag: "a1b2c3"

# next request
If-None-Match: "a1b2c3"

# response if unchanged
HTTP/1.1 304 Not Modified

Two things worth knowing.

Weak versus strong. ETag: W/"abc" means semantically equivalent but not byte identical. Use weak ETags if your response goes through gzip at a proxy that recompresses, because a strong ETag on a recompressed body is a lie.

Load balanced ETags must match across instances. If your ETag is a hash of the response body, fine. If it is derived from a file inode or a process local counter, different instances produce different ETags for the same content and every request revalidates to a 200. This shows up as a cache that appears to do nothing, and it is worth checking if your hit rate is suspiciously low.

Debugging a cache

Check what the origin sent versus what you received:

curl -sI https://example.com/app.js | grep -iE "cache-control|etag|age|x-cache"

Age tells you how long the response has been in a shared cache. A large Age on something you expected to be fresh means a CDN is holding it.

X-Cache: HIT or CF-Cache-Status: HIT names the layer. Providers differ in the header name.

Test revalidation:

etag=$(curl -sI https://example.com/api/data | grep -i etag | cut -d' ' -f2 | tr -d '\r')
curl -sI https://example.com/api/data -H "If-None-Match: $etag"
# expect 304

Bypass the browser cache without disabling it globally: DevTools Network tab, "Disable cache" checkbox, which only applies while DevTools is open.

Check the disk cache at chrome://net-export if a response is being served from somewhere you cannot account for.

The layers

A response passes through several caches and each can be configured independently:

  1. Browser memory cache
  2. Browser disk cache
  3. Service worker, if present, which can ignore your headers entirely
  4. Corporate or ISP proxy
  5. CDN
  6. Reverse proxy such as nginx
  7. Application cache

A service worker is worth calling out because it operates before the HTTP cache and implements whatever caching strategy its code says. If your headers look right and behaviour is wrong, check whether a service worker is intercepting. A stale service worker serving old assets after a deploy is a genuinely common and confusing failure.

A practical policy

Content Header
Hashed JS, CSS, fonts public, max-age=31536000, immutable
Unhashed images public, max-age=86400
HTML public, max-age=0, s-maxage=60, stale-while-revalidate=300
Public API, changes slowly public, max-age=60, stale-while-revalidate=300
Authenticated API private, no-cache plus ETag
Anything sensitive no-store
Redirects you might change no-store, because a cached 301 is permanent and unfixable

That last row deserves emphasis. A browser that caches a 301 will not ask again, possibly ever. If there is any chance you will change a redirect, use 302 or send no-store with the 301. Undoing a cached permanent redirect means asking every affected user to clear their cache, which is not a plan.