HTTP Cache-Control deep dive: max-age, s-maxage, must-revalidate, immutable, and stale-while-revalidate
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:
- Storage permission:
no-store/private/public - Freshness:
max-age/s-maxage - Revalidation:
no-cache/must-revalidate/proxy-revalidate - “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 | Directive | Browser | CDN / 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-ageapplies to all caches (browser + CDN).s-maxageapplies 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:
- Browser does store the response in cache.
- Next time the resource is needed, it sends a conditional request (
If-None-Match/If-Modified-Since) to the origin. - 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 case | Recommended |
|---|---|
| Hash-versioned static asset | public, max-age=31536000, immutable |
| Static asset, no hash | public, max-age=300, stale-while-revalidate=86400 |
| HTML where freshness matters | private, no-cache |
| Logged-in API response | private, max-age=0, must-revalidate |
| Sensitive data (cards, PII) | no-store |
| Long CDN + short browser | public, max-age=60, s-maxage=86400, stale-while-revalidate=3600 |
| With outage fallback | append stale-if-error=86400 |
CDN interpretation differences
| Directive | Cloudflare | Fastly | CloudFront | Akamai |
|---|---|---|---|---|
s-maxage | ✓ | ✓ | ✓ | ✓ |
stale-while-revalidate | ✓ | ✓ | △ (needs Lambda@Edge) | ✓ |
stale-if-error | ✓ | ✓ | ✗ | ✓ |
immutable | ✓ | ✓ | △ (browser-side only) | ✓ |
private semantics | shared no-store | shared no-store | shared no-store | shared 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.