HTTP Cache-Control deep dive: max-age, s-maxage, must-revalidate, immutable, and stale-while-revalidate

5 min read

Most developers know Cache-Control as “set max-age=3600 and you’re done.” In practice the header has more than ten directives, and their interactions are where production bugs live: “browser refuses to cache,” “CDN’s TTL doesn’t match expectations,” “purged the cache but stale responses keep coming back.” This article maps the directives by role and the combinations that actually matter.

The four directive roles

Cache-Control directives fall into four categories:

  1. Storage permission: no-store / private / public
  2. Freshness: max-age / s-maxage
  3. Revalidation: no-cache / must-revalidate / proxy-revalidate
  4. “Won’t change” promises: immutable / stale-while-revalidate / stale-if-error

Pick at most one within each role; the combination across roles defines the actual behavior.

Storage permission

no-store: never stored

Cache-Control: no-store

The strictest setting. No browser disk cache, no CDN edge cache, no reverse-proxy storage. Use for credit card data, PII — anything that should never touch persistent storage.

no-store does not prevent in-memory transient buffers (HTTP/2 retransmit, etc.).

private vs public

Cache-Control: public, max-age=3600
Cache-Control: private, max-age=3600
DirectiveBrowserCDN / shared proxy
public✓ may cache✓ may cache
private✓ may cache✗ may NOT cache

private carries “this is per-user content; only the user’s browser may store it.” Used for logged-in dashboards, email message bodies, etc.

The Authorization header trap

Responses to requests carrying an Authorization header are not stored in shared caches by default (RFC 9111 §3.5). Without explicit public, even an HTTP API behind a CDN won’t cache:

GET /api/users HTTP/1.1
Authorization: Bearer ...

# Response — explicit `public` overrides the default
Cache-Control: public, max-age=300

A common surprise when migrating to Bearer-token APIs.

Freshness

max-age vs s-maxage

Cache-Control: max-age=60, s-maxage=3600
  • max-age applies to all caches (browser + CDN).
  • s-maxage applies only to shared caches; browsers ignore it.

“Browser TTL 60s, CDN TTL 1 hour” — short browser cache for freshness, long CDN cache for origin offload. Standard pattern.

Relationship to Expires

Cache-Control: max-age=3600
Expires: Thu, 01 Dec 2026 16:00:00 GMT

When both are present, Cache-Control: max-age wins (RFC 9111 §5.3). Expires lingers for HTTP/1.0 compatibility; modern responses can omit it.

Revalidation

no-cache is NOT “do not cache”

The most-misread directive. no-cache means “revalidate every time before using”, not “do not store”:

Cache-Control: no-cache

Behavior:

  1. Browser does store the response in cache.
  2. Next time the resource is needed, it sends a conditional request (If-None-Match / If-Modified-Since) to the origin.
  3. If origin returns 304 Not Modified, use the cached body.

“Stored, but always revalidated.” Often paired with max-age=0:

Cache-Control: no-cache, max-age=0
# equivalent to no-cache alone

must-revalidate

Controls behavior after max-age expires:

Cache-Control: max-age=3600, must-revalidate
  • Within max-age: serve from cache directly.
  • Past max-age: must revalidate with origin. If origin is unreachable, return an error (don’t serve stale).

Without must-revalidate, RFC 9111 §4.2.4 permits serving stale during origin outages. Add it when stale data is unacceptable.

proxy-revalidate

Same as must-revalidate but applies only to shared caches. Browsers ignore it.

“Won’t change” guarantees and stale serving

immutable

Cache-Control: max-age=31536000, immutable

Strong promise: “the body at this URL never changes.” Browsers skip revalidation entirely within max-age.

Hash-versioned static assets (/static/app.a3f8c2.js) are the canonical use. Bundlers emit hashed URLs, so changing content always means changing URL — making 1-year immutable caching safe.

stale-while-revalidate

Cache-Control: max-age=300, stale-while-revalidate=86400

“Past expiration but within 1 day, serve stale immediately while revalidating in background.”

  • 0–300s: serve fresh.
  • 300–86700s: serve stale instantly + background-fetch the origin.
  • 86700s+: standard expired behavior.

Lets you reduce origin load without hurting perceived latency. Supported by Cloudflare, Fastly, Vercel.

stale-if-error

Cache-Control: max-age=300, stale-if-error=86400

“Past expiration, serve stale if origin returns an error.” Operational fallback during outages.

Combinations cheat-sheet

Use caseRecommended
Hash-versioned static assetpublic, max-age=31536000, immutable
Static asset, no hashpublic, max-age=300, stale-while-revalidate=86400
HTML where freshness mattersprivate, no-cache
Logged-in API responseprivate, max-age=0, must-revalidate
Sensitive data (cards, PII)no-store
Long CDN + short browserpublic, max-age=60, s-maxage=86400, stale-while-revalidate=3600
With outage fallbackappend stale-if-error=86400

CDN interpretation differences

DirectiveCloudflareFastlyCloudFrontAkamai
s-maxage
stale-while-revalidate△ (needs Lambda@Edge)
stale-if-error
immutable△ (browser-side only)
private semanticsshared no-storeshared no-storeshared no-storeshared no-store

CloudFront is the weakest on stale-* directives; consider Lambda@Edge / CloudFront Functions to compensate.

Pitfalls

1. Missing Vary

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

Without Vary: Accept-Encoding, the same URL may serve gzip to a non-supporting client. Always declare Vary for any encoding/language-dependent response.

2. Query strings and cache keys

URL versioning (?v=123) requires the CDN to include the query string in the cache key. Cloudflare does by default; CloudFront requires configuration.

3. Setting only Expires

Some legacy systems still set Expires and not Cache-Control. Without Cache-Control, shared caches behave conservatively and may not store at all. Set both, or Cache-Control alone.

Summary

Cache-Control behavior is storage × freshness × revalidation × stale-serving. “Just max-age=3600” works for static assets but fails for API responses and authenticated HTML. Start from the use-case cheat-sheet, then layer in s-maxage for CDN tuning and stale-while-revalidate for the latency wins.

For HTTP status code reference and grouping, the HTTP status tool covers the spec families.