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; }
Request Directives: What a Client Can Ask For
Almost every discussion of Cache-Control covers only the response side, but RFC 9111 §5.2.1 defines an equally complete request-directive vocabulary. These directives travel from the client toward the origin and modify how caches along the path are permitted to answer. They matter in two situations: when you are debugging (because your curl invocation may be quietly changing the behaviour you are trying to observe), and when you are writing a client — a mobile app, a server-to-server integration, a synthetic monitor — that needs a stricter or looser contract than the default.
Cache-Control: no-cache on a request means the client will not accept a stored response that has not been revalidated. Every cache in the path must forward the request to the origin, or revalidate its stored copy, before answering. This is what a browser sends on a soft reload, and it is why a page can look correct after a refresh and wrong on the next navigation. no-store on a request additionally asks that nothing about this exchange be written to storage anywhere — a request that carries it should leave no trace in any intermediary.
max-age=N on a request caps how old an answer the client is willing to accept, independently of what the origin authorised. A client sending max-age=0 is asking for a revalidation on every request; one sending max-age=600 will accept a ten-minute-old copy even if the origin published a shorter lifetime. min-fresh=N is the same idea expressed forward in time: the client wants a response that will remain fresh for at least another N seconds, which is useful for a client that intends to hold the answer without re-requesting.
Two directives go the other way. max-stale=N tells caches the client will accept a response that expired up to N seconds ago — an explicit opt-in to stale content, useful for a background sync that would rather have something old than nothing. only-if-cached asks a cache to answer from storage or return 504 Gateway Timeout, never touching the origin; it is how an offline-capable client asks “do you already have this?” without paying for a network round trip.
The practical consequence for debugging is worth stating plainly: curl -H 'Cache-Control: no-cache' does not show you what a normal visitor sees, it shows you the origin. If you are trying to establish whether the edge is serving hits, sending that header guarantees it will not. Capture the default behaviour first, then use request directives deliberately to isolate a tier.
Extension Directives and Vendor Dialects
The directive list in RFC 9111 is deliberately extensible: a cache must ignore any directive it does not recognise, which allows new behaviour to be introduced without breaking existing implementations. Several widely-supported directives live outside the core specification, and knowing which is which prevents both over-reliance and unnecessary caution.
immutable comes from RFC 8246. It tells a browser that the representation will not change for the whole of its freshness lifetime, so a reload should not trigger a conditional request. Firefox, Safari and Chromium all honour it; caches that do not simply ignore it, which costs nothing. stale-while-revalidate and stale-if-error come from RFC 5861, and are honoured by most CDNs and by Chromium-based browsers; caches without support treat the response as plainly expired, which is the safe fallback.
Vendor dialects are a different matter. Fastly’s Surrogate-Control header carries a directive set that looks like Cache-Control but is consumed and stripped by the edge, so it never reaches the browser — which is the cleanest way to give the edge a long lifetime without publishing it to clients. Cloudflare reads Cache-Control directly but layers dashboard-level TTL settings on top, and which one wins depends on the specific setting. Akamai’s Edge-Control plays the same role as Surrogate-Control. None of these are portable, and a header set that depends on one of them needs a documented fallback for the day the CDN changes.
The rule that keeps this manageable: express intent in standard directives first, and reach for a vendor header only when the standard vocabulary genuinely cannot say what you mean — most often when you want the edge lifetime to be invisible to browsers. Everything else belongs on the Cache-Control line, where every cache in the path can read it.
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.
Topics in This Section
Every topic area below sits under Directives and links on to its own step-by-step guides.
- max-age & s-maxage — Precise guide to max-age and s-maxage Cache-Control directives.
- no-cache vs no-store — RFC 9111 guide to Cache-Control no-cache and no-store.
- Public vs Private Cache Scope — Guide to Cache-Control public and private directives.
- Header Stacking & Directive Precedence — How RFC 9111 resolves multiple Cache-Control directives when headers are stacked by proxies, CDNs, or frameworks — precedence rules and debugging workflows.
- Immutable & Versioned Asset Caching — How the immutable Cache-Control extension (RFC 8246) paired with content-hashed URLs and a year-long max-age eliminates revalidation requests on reload.
- must-revalidate and proxy-revalidate — The two directives that remove a cache’s discretion to serve stale content.
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.