Cache-Control caching both
The primary mechanism for controlling how and for how long responses are cached by browsers, CDNs, and proxies.
What it does
Cache-Control is the primary tool for controlling caching behaviour in HTTP. It tells browsers, CDNs, and intermediate proxies whether they can cache a response, for how long, and under what conditions they must revalidate before serving a cached copy.
It works in both directions: a response Cache-Control tells caches what to do with the response; a request Cache-Control lets clients control how they want caches to behave when fetching.
Syntax
Cache-Control: <directive>
Cache-Control: <directive>, <directive>
Multiple directives are comma-separated. Directives are case-insensitive.
Response directives (server → client/cache)
These are the directives you set on your server responses.
| Directive | Meaning |
|---|---|
max-age=<seconds> |
The response is fresh for this many seconds from the time it was generated. The most commonly used directive. |
s-maxage=<seconds> |
Like max-age but only for shared caches (CDNs, proxies). Overrides max-age and Expires for shared caches. Browsers ignore it. |
no-cache |
The response can be cached, but the cache must revalidate with the origin before serving it. Despite the name, it does NOT mean "don't cache." |
no-store |
The response must NOT be cached anywhere — not in browser cache, not in CDN, not in proxy. Use for sensitive data. |
private |
Only the end-user's browser can cache this. CDNs and shared proxies must not cache it. |
public |
Any cache can store this, including shared caches. Useful when setting alongside Authorization (which would otherwise make responses private by default). |
must-revalidate |
Once the response is stale, the cache must revalidate before serving it. It cannot serve a stale response if revalidation fails. |
proxy-revalidate |
Like must-revalidate but only applies to shared caches. |
immutable |
The response will not change while it's fresh. Tells browsers not to revalidate during page reload — great for versioned assets. |
stale-while-revalidate=<seconds> |
Serve the stale cached response while revalidating in the background, for up to this many extra seconds past max-age. |
stale-if-error=<seconds> |
Serve the stale cached response if revalidation fails, for up to this many seconds. |
no-transform |
Intermediaries (like CDNs that compress images) must not transform the response body. |
Request directives (client → cache)
These are less commonly hand-crafted but worth knowing — browsers and HTTP clients send them automatically in some situations.
| Directive | Meaning |
|---|---|
no-cache |
Force the cache to revalidate with the origin before responding. |
no-store |
Don't cache this request or its response. |
max-age=0 |
Treat any cached response as stale — effectively forces revalidation. |
max-stale[=<seconds>] |
Accept a stale response, optionally within a given staleness window. |
min-fresh=<seconds> |
Only accept a response that will still be fresh for at least this many seconds. |
only-if-cached |
Only return a cached response; fail with 504 if none exists. |
The no-cache vs no-store confusion
This trips up almost everyone:
no-cache: "Cache it, but always check with the server before using it." The cached copy is used only if the server confirms it's still valid (via 304). Great for HTML pages you want cached but always up-to-date.no-store: "Don't cache this at all, ever." Nothing is written to disk or memory. Use for responses containing sensitive data — bank statements, private messages, admin dashboards.
max-age vs s-maxage
Cache-Control: max-age=3600, s-maxage=86400
This tells browsers to cache for 1 hour, but CDNs can cache for 24 hours. Useful when you want aggressive CDN caching but don't want browsers holding on to stale content for too long. s-maxage is invisible to browsers — they only see max-age.
Real-world patterns
Versioned static assets (JS, CSS, images with hashed filenames):
Cache-Control: public, max-age=31536000, immutable
Cache forever. The filename hash changes when content changes, so there's no staleness problem.
HTML pages:
Cache-Control: no-cache
Always revalidate. The browser caches the page but checks if it's still valid on each visit. With ETags or Last-Modified, this is very cheap (304 responses send no body).
API responses:
Cache-Control: no-store
Or if cacheable:
Cache-Control: private, max-age=60
CDN-cached but user-specific content:
Cache-Control: private, max-age=300
Browser can cache for 5 minutes; CDN cannot (private).
Aggressive CDN caching with background revalidation:
Cache-Control: public, max-age=60, stale-while-revalidate=3600
Serve fresh for 60s, then serve stale while revalidating in background for up to 1 hour. Gives CDN cache the appearance of always-fresh content without hammering the origin.
How it interacts with other headers
Cache-Control: max-age takes precedence over Expires when both are present. Expires is the older HTTP/1.0 mechanism; Cache-Control is preferred in all modern setups.
Age tells you how old a cached response already is — a response with max-age=3600 and Age: 1800 has 30 minutes of freshness left.
ETag and Last-Modified power the revalidation that no-cache and must-revalidate trigger. Without them, revalidation always results in a full re-download.
Vary tells caches to key cached responses by request headers. Vary: Accept-Encoding combined with Cache-Control: public means the CDN stores separate compressed/uncompressed versions.
Common mistakes and gotchas
Using no-cache when you mean no-store. For sensitive data, you want no-store. no-cache still caches — it just always revalidates.
Forgetting s-maxage for CDNs. If you set Cache-Control: private for user-specific content, your CDN won't cache it. If you want CDN caching for shared content, use public or s-maxage explicitly.
Not setting Cache-Control at all. Caches use heuristic caching when no Cache-Control is present — they might cache your responses for an arbitrary duration based on Last-Modified. Explicit is always better.
Setting immutable on non-versioned assets. immutable tells browsers never to revalidate while fresh. If you deploy an update to /style.css (no hash in the URL), browsers with immutable cached versions will keep serving the old file until max-age expires.
FAQ
Does Cache-Control: no-cache actually prevent caching?
No — this is one of HTTP's most confusing naming decisions. no-cache means "cache it, but revalidate before every use." If you genuinely want no caching, use no-store. The name made more sense in the original context where "no-cache" meant "don't serve from cache without checking" — but in plain English it reads as "don't cache," which it doesn't mean.
What's the difference between must-revalidate and no-cache?
Both require revalidation when the response is stale. The difference: no-cache requires revalidation even when the response is still fresh. must-revalidate only kicks in once the response becomes stale (past max-age). For most HTML pages, no-cache is what you want.
Can I use Cache-Control on requests?
Yes, but in practice browsers handle request Cache-Control automatically. When a user hard-refreshes (Ctrl+Shift+R), the browser sends Cache-Control: no-cache on all requests. When a user does a normal navigation, the browser handles caching transparently. You'd set request Cache-Control manually in API clients or when building custom HTTP tooling.
Does Cache-Control work the same in HTTP/2 and HTTP/3?
Yes — Cache-Control semantics are identical across HTTP versions. HTTP/2 and HTTP/3 change the transport layer, not the caching model.
Fun fact
Cache-Control was introduced in HTTP/1.1 (1997) specifically to replace Expires, which had a critical flaw: it required clock synchronisation between client and server. If your server clock was wrong, your cache lifetimes were wrong. max-age in Cache-Control uses relative time (seconds from now) instead of an absolute date, completely sidestepping the clock problem. Despite Expires being deprecated for this use case 25+ years ago, you'll still find it set alongside Cache-Control on responses from virtually every major CDN — mostly for legacy HTTP/1.0 client compatibility that almost certainly no longer exists.