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-Store is equivalent to no-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=3600 require a non-negative integer — no unit suffixes, no decimals.
  • Unknown directives must be ignored by compliant implementations, so vendor extensions like stale-while-revalidate degrade 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.

Cache-Control Request Flow Diagram showing how an HTTP request travels through a browser cache, CDN edge node, and origin server, with Cache-Control directive decisions at each tier. BROWSER CDN EDGE ORIGIN Client Request GET /resource Browser Cache Check max-age vs Age private / no-store? HIT Serve from cache 200 (from disk cache) MISS no-cache CDN Edge Cache Check s-maxage vs Age public / private? HIT Serve from edge Age: <seconds> header added MISS Origin Server Generate response Set Cache-Control Conditional Revalidation If-None-Match / If-Modified-Since stale · no-cache · must-revalidate 304 Not Modified or 200 with new body Updates cache entry Legend Cache HIT Cache MISS no-cache path Origin response Directives by scope Browser only max-age, no-store, private must-revalidate, immutable Shared caches only s-maxage, public proxy-revalidate All caches no-cache, no-transform stale-while-revalidate stale-while-revalidate window Serve stale immediately; revalidate in the background Next request gets the fresh copy — zero added latency no-store: highest storage prohibition Overrides max-age, s-maxage, public — never written to disk or memory

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:

  1. Open DevTools → Network tab.
  2. Enable “Disable cache” to force origin fetches and establish baseline headers.
  3. Disable “Disable cache”, reload, and check the Size column — “(disk cache)” or “(memory cache)” confirms a browser HIT.
  4. Right-click a request → “Copy as cURL” to reproduce the exact headers sent.
  5. 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.


Back to Core Caching Fundamentals