Cache-Control Directives & Header Combinations
Correct Cache-Control configuration requires understanding not just individual directives but how they interact, which tiers they govern, and where they conflict. This reference covers the full directive taxonomy defined by RFC 9111, the precedence rules that resolve conflicts, and production-ready header patterns for browser, CDN, and origin caching. Whether you are tuning TTLs for a static asset pipeline, protecting authenticated API responses, or diagnosing stale-content bugs, the sections below map every decision point. Start with Mastering max-age and s-maxage Directives for TTL mechanics, no-cache vs no-store: When to Use Each for validation semantics, or Header Stacking and Directive Precedence for conflict resolution.
RFC / Spec Anchor
RFC 9111 (published June 2022) is the normative specification for HTTP caching. It replaced RFC 7234 and is the governing reference for all modern caches — browsers, CDN edge nodes, reverse proxies, and language-level HTTP clients alike. HTTP/2 and HTTP/3 transport layers do not alter caching semantics; Cache-Control behavior is identical across protocol versions.
Key RFC 9111 sections for directives:
| Section | Topic |
|---|---|
| §5.2 | Cache-Control header field and all directive definitions |
| §5.2.1 | Request directives (no-cache, no-store, max-age, max-stale, min-fresh, no-transform, only-if-cached) |
| §5.2.2 | Response directives (max-age, s-maxage, no-cache, no-store, no-transform, must-revalidate, proxy-revalidate, public, private, must-understand) |
| §5.3 | Calculating freshness lifetime; Expires demotion when Cache-Control is present |
| §4.2 | Freshness model; age calculation from Age, Date, and Last-Modified |
| §4.3 | Validation model; ETag / Last-Modified conditional requests |
Directive syntax rules:
- Tokens are case-insensitive (
No-Storeis equivalent tono-store). - Multiple directives are comma-delimited:
Cache-Control: public, max-age=86400, stale-while-revalidate=3600. - Parsers must tolerate arbitrary whitespace around commas (RFC 9110 §5.6.1).
- Argument values such as
max-age=3600require a non-negative integer — no unit suffixes, no decimals. - Unknown directives must be ignored by compliant implementations, so vendor extensions like
stale-while-revalidatedegrade gracefully on older caches.
Precedence decision table (highest wins):
| Priority | Rule |
|---|---|
| 1 | no-store — prevents any storage; overrides all freshness and visibility directives |
| 2 | Cache-Control freshness directives — override Expires and Pragma: no-cache |
| 3 | s-maxage — overrides max-age for shared caches only |
| 4 | max-age — overrides heuristic freshness for all caches |
| 5 | Expires — used only when no Cache-Control freshness directive is present |
| 6 | Heuristic freshness — last resort, typically 10% of Last-Modified age |
Concept Map & Terminology
The following terms appear throughout the child pages of this section. Each definition links forward to the page that covers it in depth.
max-age — A freshness directive that sets the maximum number of seconds a response may be considered fresh, measured from the time of the response. All caches honor it unless overridden by s-maxage in a shared cache.
s-maxage — A shared-cache freshness directive that overrides max-age for CDNs and reverse proxies. Browsers ignore it by design. Use it to maintain a longer CDN TTL than the browser TTL in the same header.
no-cache — A validation directive that requires any cache to revalidate with the origin before serving a stored response. The response may be stored; it just cannot be used without checking. Commonly misread as “do not cache.”
no-store — A storage prohibition directive (RFC 9111 §5.2.2.5) that prevents any cache from recording any part of the request or response. Use exclusively for responses containing credentials, session data, or PII.
public — A visibility directive explicitly permitting shared caches to store a response, even when it carries Authorization headers. Required in some CDN configurations to enable edge caching of authenticated routes.
private — A visibility directive restricting storage to the end-user’s own cache (browser). CDNs and reverse proxies must not cache a private response.
must-revalidate — A staleness enforcement directive that prohibits a cache from serving a stale response. If the origin is unreachable after the freshness window expires, the cache must return a 504 Gateway Timeout rather than serving stale content.
stale-while-revalidate — An extension directive (RFC 5861, widely adopted) that allows a cache to serve a stale response for a defined window while it revalidates asynchronously in the background. See how to combine Cache-Control directives safely.
immutable — An extension directive signalling that the response body will not change during its freshness lifetime. Browsers skip the conditional revalidation request on page reload for immutable resources, provided the URL includes a content hash.
no-transform — A mutation-restriction directive that prevents intermediaries from modifying the response body or Content-Encoding. Critical for compressed or signed assets.
Architecture Overview
The diagram below shows the complete request path through a multi-tier cache stack and how Cache-Control directives govern decisions at each hop.
Cross-cutting Patterns
1. Immutable Static Assets with Content-Hashed URLs
Use this pattern for JavaScript bundles, CSS files, and images whose filenames include a build-time content hash.
Cache-Control: public, max-age=31536000, immutable
The immutable directive tells browsers not to issue conditional revalidation requests during the one-year freshness window. Because the filename changes whenever the content changes, the URL is effectively permanent. CDNs cache these responses at the edge; the cache must be purged by deploying new hashed filenames — not by changing headers.
Do not apply immutable to URLs that can serve different content over time. Without a content hash in the URL, a cached immutable response will silently serve stale content to users for the full max-age period.
2. CDN–Browser Split TTL for API Responses
Set a short browser TTL to limit stale data exposure to end users while keeping a longer shared-cache TTL at the CDN edge for throughput.
Cache-Control: public, s-maxage=300, max-age=60, stale-while-revalidate=30
CDN edge nodes respect s-maxage=300 (5 minutes) and ignore max-age. Browsers respect max-age=60 (1 minute) and ignore s-maxage. The stale-while-revalidate=30 window allows both tiers to serve the existing cached copy for an additional 30 seconds while a background revalidation request completes — eliminating revalidation latency from the response path.
3. User-Authenticated Responses
Prevent shared caches from storing responses that carry user-specific data.
Cache-Control: private, no-cache
private confines storage to the browser’s personal cache. no-cache requires the browser to revalidate before serving the stored response, ensuring the user always sees up-to-date content. Do not add public or s-maxage to authenticated endpoints — either directive would permit CDN caching and risk leaking one user’s data to another.
4. HTML Documents with Immediate Invalidation
HTML documents typically reference versioned assets but themselves must not be served stale. This pattern forces revalidation on every request while still benefiting from the bandwidth savings of a 304 Not Modified.
Cache-Control: no-cache
ETag: "d8e8fca"
Last-Modified: Mon, 17 Jun 2026 12:00:00 GMT
The response is stored but always revalidated. If the document has not changed, the origin returns 304 with no body — a fraction of the cost of a full response — and the browser updates the stored copy’s timestamp.
Directive Taxonomy
All Cache-Control response directives fall into four non-overlapping groups:
| Group | Directives | Governs |
|---|---|---|
| Freshness | max-age, s-maxage, stale-while-revalidate, stale-if-error |
How long a response is valid before staleness triggers action |
| Validation | no-cache, must-revalidate, proxy-revalidate |
Whether and when origin confirmation is required |
| Visibility | public, private |
Which cache tiers may store the response |
| Mutation | no-transform, immutable |
Whether intermediaries may alter or assume stability of the response body |
A freshness directive answers when to check; a validation directive answers how strictly to check; a visibility directive answers who may store; a mutation directive answers what may be modified in transit.
Browser vs. shared cache scope:
| Directive | Browser | CDN / Reverse Proxy |
|---|---|---|
max-age |
Honored | Honored (unless s-maxage present) |
s-maxage |
Ignored | Honored; overrides max-age |
private |
Stores | Must not store |
public |
Stores | Explicitly permitted to store |
proxy-revalidate |
Ignored | Must revalidate on stale |
must-revalidate |
Honored | Honored |
Vendor Support Matrix
| Implementation | s-maxage |
stale-while-revalidate |
immutable |
no-transform |
|---|---|---|---|---|
| Chrome 120+ | Ignored (private cache) | Supported | Supported | Honored |
| Firefox 121+ | Ignored (private cache) | Supported | Supported | Honored |
| Safari 17.4+ | Ignored (private cache) | Supported | Supported | Honored |
| Cloudflare | Respected | Supported | Passes through | Respected |
| Fastly | Respected | Supported | Passes through | Respected |
| AWS CloudFront | Respected | Not native | Passes through | Respected |
| Nginx (proxy_cache) | Respected | Not supported natively | Passes through | Not enforced |
| Varnish | Respected | Via VCL only | Passes through | Via VCL only |
All browsers ignore s-maxage by specification — it is a shared-cache directive. This is correct per RFC 9111, not a browser defect.
Diagnostic & Debugging Reference
Inspect origin headers, bypassing all intermediate caches:
curl -sI \
-H "Cache-Control: no-cache" \
-H "Pragma: no-cache" \
https://example.com/api/data
Verify Cache-Control, Age, Vary, ETag, and X-Cache in the response. A missing Age header on a CDN response indicates a cache miss or bypass. An Age value near zero on repeated requests may indicate a very short TTL or no-cache.
Force a conditional request to test validator behavior:
# 1. Fetch and capture the ETag
ETAG=$(curl -sI https://example.com/resource | grep -i '^etag' | awk '{print $2}' | tr -d '\r')
# 2. Send a conditional request
curl -sI -H "If-None-Match: $ETAG" https://example.com/resource
# Expect: HTTP/2 304
Chrome DevTools workflow:
- Open DevTools → Network tab.
- Enable “Disable cache” to force origin fetches and establish baseline headers.
- Disable “Disable cache”, reload, and check the Size column — “(disk cache)” or “(memory cache)” confirms a browser HIT.
- Right-click a request → “Copy as cURL” to reproduce the exact headers sent.
- For CDN diagnosis, check vendor-specific headers:
CF-Cache-Status(Cloudflare),X-Cache(CloudFront, Fastly),X-Varnish(Varnish).
CI/CD header validation:
# Assert max-age on a static asset
CACHE_HEADER=$(curl -sI https://example.com/app.a3f9b2.js | grep -i 'cache-control')
echo "$CACHE_HEADER" | grep -q "immutable" || { echo "FAIL: immutable missing"; exit 1; }
echo "$CACHE_HEADER" | grep -q "max-age=31536000" || { echo "FAIL: wrong max-age"; exit 1; }
Common Mistakes & RFC Violations
| Anti-pattern | RFC rule broken | Correct approach |
|---|---|---|
Cache-Control: no-cache, no-store, max-age=0 together |
Redundant; no-store alone is sufficient (RFC 9111 §5.2.2.5) |
Use no-store alone for PII/session responses |
Cache-Control: public on a response with Set-Cookie |
RFC 9111 §5.3 — Set-Cookie fields make responses uncacheable by default unless explicitly marked public |
Use private or omit public unless sharing the cookie-set response is intentional |
max-age=0 without must-revalidate |
Stale-serving is still permitted under RFC 9111 §4.2.4 without must-revalidate |
Add must-revalidate or use no-cache to mandate revalidation |
Using immutable without a content-hashed URL |
Browsers will serve the cached copy for the full TTL even after content changes | Only apply immutable to URLs that change when the file changes |
s-maxage on a private response |
private prohibits shared-cache storage, making s-maxage meaningless (RFC 9111 §5.2.2.7) |
Separate private and public TTL directives into distinct routes |
Expires and max-age both set |
Cache-Control wins per RFC 9111 §5.3, making Expires redundant clutter |
Remove Expires from any response that carries Cache-Control |
no-cache misread as “do not cache” |
RFC 9111 §5.2.2.4 — no-cache permits storage; it mandates revalidation |
Use no-store when storage must be prevented; use no-cache when revalidation is required |
FAQ
Can max-age and s-maxage coexist in the same Cache-Control header?
Yes. When both appear, shared caches (CDNs, reverse proxies) use s-maxage and ignore max-age. Browsers use max-age and ignore s-maxage. This lets you set different TTLs for the two tiers in a single header value — for example, Cache-Control: public, s-maxage=3600, max-age=60 gives CDNs a one-hour TTL while browsers revalidate after one minute.
Does no-store override everything else in the header?
For storage, yes. RFC 9111 §5.2.2.5 requires that a cache receiving no-store must not store any part of the request or response, regardless of any other directives present. no-store in combination with max-age, public, or s-maxage — while contradictory — always results in no storage.
What is the difference between no-cache and must-revalidate?
no-cache requires the cache to revalidate with the origin before serving any stored response, even a fresh one. must-revalidate only activates once the response has become stale — a fresh response can be served without revalidation. For documents that must always reflect the latest server state, no-cache is the correct choice.
Which header wins when Cache-Control and Expires both appear?
Cache-Control wins. RFC 9111 §5.3 explicitly states that a cache must prioritize the max-age directive over the Expires header when both are present.
Is Cache-Control behavior the same over HTTP/2 and HTTP/3?
Yes. HTTP/2 and HTTP/3 change the transport and framing layers but do not alter caching semantics. Cache-Control directives behave identically across all three protocol versions, so existing header configurations require no modification when upgrading protocol.
When should I use immutable?
Use immutable alongside a long max-age only when the URL is permanently tied to a specific content version — typically through a content hash in the filename (e.g. app.a3f9b2.js). It signals to browsers that the resource will never change at this URL, preventing unnecessary conditional revalidation requests during the freshness window and reducing origin load on page reload.
Related
- The CDN Architecture & Edge Routing Strategies section covers how CDN cache keys,
Varyheader routing, and origin shielding interact with the directives described here. - Public vs Private Cache Scope explains in depth how
publicandprivategovern multi-tier storage and how to prevent cross-user data leakage at CDN edges. - Cache Hit, Miss, and Bypass Mechanics explains how the freshness and validation models described above translate into observable cache outcomes and
X-Cacheheader values. - What Happens When max-age Expires details the stale-state transition and what triggers revalidation versus serving stale.
- How to Combine Cache-Control Directives Safely provides a decision guide for multi-directive combinations and common conflict resolution.