Header Stacking and Directive Precedence in Cache-Control
TL;DR: RFC 9111 requires every Cache-Control header field in a response to be merged into one comma-separated list before directive resolution. Precedence is semantic, not positional — no-store beats no-cache beats private/public beats max-age/s-maxage beats extension directives. Real-world CDN parsers sometimes deviate; normalise to one canonical header at your reverse proxy to eliminate stacking problems entirely.
Quick-reference header block — canonical pattern for stacked environments:
Cache-Control: no-store
Cache-Control: no-cache
Cache-Control: public, s-maxage=86400, max-age=3600, stale-while-revalidate=86400
Mechanism & RFC Alignment
HTTP header stacking occurs when multiple Cache-Control directives enter the request–response chain — either as comma-separated values within a single header field, or as duplicate Cache-Control header lines emitted by different layers (framework, application server, reverse proxy, CDN rule).
RFC 9111 Section 5.2 mandates the resolution rule explicitly:
“If a Cache-Control header field appears more than once in a message, the recipient MUST treat it as if each field-value were appended to the other, with a comma separator.”
This comma-merge rule means the following two representations are semantically identical from a conforming cache’s perspective:
Cache-Control: max-age=3600
Cache-Control: stale-while-revalidate=86400
Cache-Control: max-age=3600, stale-while-revalidate=86400
Once merged, the cache evaluates the full directive set as a whole. There is no positional priority — a directive that appears second in the string carries the same normative weight as one that appears first.
Why stacking happens in practice. In microservice and middleware-heavy architectures, each layer in the stack commonly appends its own caching intent:
- An Express middleware sets
Cache-Control: no-cachefor authenticated routes. - The same response passes through an Nginx proxy that appends
max-age=600viaadd_header. - A CDN edge rule injects
s-maxage=3600unconditionally.
The result is a merged string that contains conflicting directives. How each cache resolves the conflict depends on which conflict-resolution rule it implements — and RFC 9111 compliance is not universal.
Scope & Precedence
The following diagram illustrates how the RFC’s semantic hierarchy resolves conflicts across cache tiers when directives stack.
When directives from different priority tiers coexist, the higher-tier directive wins unconditionally. Within the same tier (e.g. two freshness values), RFC 9111 does not define a winner — behaviour is implementation-specific.
Tier-by-tier breakdown:
| Priority | Directive(s) | Applies to | Beats |
|---|---|---|---|
| 1 | no-store |
All caches | Everything |
| 2 | no-cache |
All caches | Scope + freshness + extensions |
| 3 | private / public |
Shared caches only | Freshness + extensions |
| 4 | max-age / s-maxage |
Private / shared | Extensions only |
| 5 | stale-while-revalidate, stale-if-error, immutable |
Varies | Nothing else |
Critical deviation: first-match-wins CDN parsers. While RFC 9111 requires comma-merge plus semantic resolution, several CDN implementations — including some Fastly and Varnish configurations — apply first-match-wins logic. A response that arrives at the CDN as Cache-Control: max-age=3600, no-cache may be cached for an hour by a first-match-wins parser. Always verify your CDN’s documented conflict-resolution behaviour and normalise before the CDN boundary.
Implementation Patterns
1. Authenticated API response — force revalidation
HTTP/1.1 200 OK
Cache-Control: no-cache, private
Vary: Authorization
no-cache ensures every cache revalidates before serving. private prevents shared-cache storage. The Vary: Authorization header ensures cached variants are keyed per credential. See no-cache vs no-store: when to use each for guidance on choosing between no-cache and no-store for sensitive responses.
2. CDN split TTL — long edge cache, shorter browser cache
HTTP/1.1 200 OK
Cache-Control: public, s-maxage=86400, max-age=3600, stale-while-revalidate=3600
Edge caches serve the response for 24 hours (s-maxage=86400). Browsers re-fetch after one hour (max-age=3600). The stale-while-revalidate=3600 window allows the CDN to serve a stale copy while it revalidates in the background, reducing origin load. This pattern is explained in depth in Mastering max-age and s-maxage Directives.
3. Immutable versioned static assets
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
Content-hashed assets (e.g. app.a3f9c2.js) never change at a given URL. immutable tells browsers not to revalidate during the freshness window — it eliminates the conditional If-None-Match request browsers would otherwise send on reload. Only apply immutable to URLs where a file change always produces a new URL.
4. Normalising stacked directives at the proxy boundary
When an upstream framework emits its own Cache-Control and you need to override it:
HTTP/1.1 200 OK
Cache-Control: no-cache
Cache-Control: max-age=300
A conforming cache merges these as Cache-Control: no-cache, max-age=300. Per the precedence table above, no-cache (P2) beats max-age (P4), so the merged result requires revalidation even though max-age=300 is present. A first-match-wins CDN parser, however, may serve the response for 300 seconds and ignore no-cache. Normalise at the reverse proxy to eliminate the ambiguity.
5. Suppressing all upstream directives — error responses
HTTP/1.1 500 Internal Server Error
Cache-Control: no-store
Error responses must not be cached. When frameworks may emit a Cache-Control header on 5xx responses, strip and replace at the proxy to guarantee no-store is the only directive present.
Server/CDN Configuration
Nginx — strip upstream and emit canonical directive
location /api/v1/ {
proxy_pass http://upstream;
proxy_hide_header Cache-Control;
add_header Cache-Control "public, s-maxage=86400, max-age=3600, stale-while-revalidate=3600" always;
}
location /api/v1/auth/ {
proxy_pass http://upstream;
proxy_hide_header Cache-Control;
add_header Cache-Control "no-store" always;
}
proxy_hide_header Cache-Control removes all upstream Cache-Control values before the CDN or browser sees them. add_header ... always emits the directive on all response codes, including 4xx and 5xx — without always, Nginx only adds the header on 2xx and 3xx responses, which allows 500 errors to pass through uncached.
Apache — conditional header replacement
Header unset Cache-Control
Header always set Cache-Control "public, s-maxage=86400, max-age=3600"
Header unset Cache-Control
Header always set Cache-Control "no-store"
Cloudflare — Cache Rules to override stacked headers
In the Cloudflare dashboard under Rules → Cache Rules, create a rule that matches the path and sets Edge Cache TTL and Browser Cache TTL explicitly. Cloudflare’s “Override origin Cache-Control” setting replaces the merged origin directive entirely with your configured values, bypassing stacking issues at the edge.
For programmatic control via the Cloudflare API:
curl -X PUT "https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets/{ruleset_id}/rules/{rule_id}" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
--data '{
"action": "set_cache_settings",
"action_parameters": {
"edge_ttl": { "mode": "override_origin", "default": 86400 },
"browser_ttl": { "mode": "override_origin", "default": 3600 }
}
}'
Interaction with Related Directives
no-cache and max-age together. When a framework appends no-cache to a response that already carries max-age, the merged directive requires revalidation before every use — max-age does not grant a freshness window that no-cache cannot revoke. The max-age value is still parsed (some extension logic uses it) but is overridden for freshness decisions.
private and s-maxage together. The combination is self-contradicting: private prohibits shared-cache storage, while s-maxage sets a TTL only meaningful to shared caches. RFC 9111 Section 5.2.2.7 specifies that s-maxage is ignored by shared caches when private is present. If you see this pattern in your headers, it is almost always a stacking artefact — a framework emitting private while an Nginx rule appends s-maxage. Strip and replace.
stale-while-revalidate under no-cache. Extension directives sit at P5. If no-cache (P2) is present, revalidation is mandatory — stale-while-revalidate cannot extend serving of a stale copy because the cache is prohibited from serving any stored copy without revalidating. The extension directive is silently ignored.
immutable under stale-while-revalidate. These do not conflict — immutable suppresses in-freshness revalidation requests from browsers, while stale-while-revalidate covers the post-freshness window. They compose correctly when both are applied to versioned assets with a CDN in front.
For the full decision matrix on combining multiple directives, see How to Combine Cache-Control Directives Safely.
Verification Workflow
Step 1: Capture the raw merged directive
curl -sI https://api.example.com/v1/resource | grep -i "cache-control"
If you see two or more cache-control: lines in the output, a stacking problem exists somewhere in the chain. The expected output is a single line containing all intended directives.
Step 2: Trace each directive to its source
# Compare origin output (bypassing CDN) with CDN output
curl -sI --resolve api.example.com:443:ORIGIN_IP https://api.example.com/v1/resource | grep -i "cache-control"
curl -sI https://api.example.com/v1/resource | grep -i "cache-control"
The diff identifies whether stacking is happening at the CDN, the reverse proxy, or the application layer.
Step 3: Confirm CDN interpretation via response headers
CDNs expose their caching decision through vendor-specific headers:
curl -sI https://api.example.com/v1/resource | grep -iE "cf-cache-status|x-cache|age|x-served-by"
A CF-Cache-Status: HIT alongside a no-cache directive in the Cache-Control header is a definitive signal that the CDN is applying first-match-wins parsing.
Step 4: DevTools inspection
- Open the Network tab and tick Disable cache.
- Reload the target resource and click the request row.
- In Response Headers, look for duplicate
Cache-Controlentries. - Compare the Age response header value against the
max-ageors-maxagevalue — a non-zeroAgeon ano-cacheresponse confirms a CDN stacking error. - Force an origin fetch by appending a cache-busting query string (
?v=1) to isolate the origin directive from the cached response.
Failure Modes & Gotchas
-
add_headerwithoutalwaysin Nginx. Nginx does not emitadd_headerdirectives on 4xx/5xx responses unlessalwaysis specified. Error pages pass through with whateverCache-Controlthe upstream set — often nothing, which triggers heuristic caching in some CDNs. -
Framework middleware adding
Cache-Controlafter your reverse proxy. Some Node.js and Django middleware runs after the response leaves your application but before Nginx can strip it — for example, middleware registered at the WSGI/ASGI server level. Always verify by comparing direct application output against the proxy output. -
CDN rules evaluated before origin headers are seen. Cloudflare’s Transform Rules run before Cache Rules. A Transform Rule that adds
Cache-Control: max-age=60will be appended to the merged string before a Cache Rule attempts to override it. Order matters — read your CDN’s pipeline documentation. -
Duplicate
Varyfields compounding with stackedCache-Control. IfVaryis also stacked (multipleVary: Accept-EncodingandVary: Acceptlines), the mergedVaryset multiplies the number of cache keys alongside any directive conflicts. See Mapping Vary Headers to Edge Routing for how CDNs derive cache keys fromVary. -
stale-if-errorignored whenno-storeis present. Becauseno-storeprevents any storage, there is no stored copy to serve under error conditions. The extension directive has no effect. -
HTTP/1.0 intermediaries and
Pragma: no-cache. Legacy proxies do not parseCache-Control. If your infrastructure includes HTTP/1.0-capable proxies,Pragma: no-cachemust accompanyno-cacheorno-storeto prevent intermediate caching. HTTP/1.1 and HTTP/2 clients ignorePragma. -
Testing with
curlonly seeing one hop.curlreports the headers you receive from the server you hit — not what intermediate CDN pops saw. Always test against the CDN origin shield URL directly (most CDNs expose an origin pull endpoint) to see the unmodified merge.
FAQ
What happens when two Cache-Control headers set different max-age values?
RFC 9111 Section 5.2 requires parsers to merge both into one directive list: Cache-Control: max-age=600, max-age=3600. The RFC does not specify which freshness value wins when the same directive appears twice — behaviour is implementation-defined. In practice most browsers and spec-compliant CDNs take the last value, but some CDNs apply first-match-wins and take the first. Normalise to a single authoritative header at your reverse proxy before the response reaches any cache.
Does no-store override no-cache when both are present?
Yes. no-store is the absolute prohibition on caching: it prohibits any storage at any layer. no-cache permits storage but requires revalidation before each use. When both appear, no-store takes effect because a cache that cannot store the response cannot serve it at all, making no-cache’s revalidation requirement moot.
How do I prevent a middleware from stacking its own Cache-Control value onto mine?
At your reverse proxy (Nginx, Apache, Caddy), strip any upstream Cache-Control with proxy_hide_header Cache-Control (Nginx) or Header unset Cache-Control (Apache) before emitting your canonical directive. This ensures only one authoritative Cache-Control leaves your infrastructure.
Does s-maxage override max-age for browsers?
No. s-maxage is respected only by shared caches — CDN edge nodes, reverse proxies, and other intermediaries. Browsers are private caches and ignore s-maxage entirely, falling back to max-age or heuristic freshness. This split lets you set different TTLs for edge caches and browsers from a single header.
Why does my CDN ignore no-cache when the origin also sends max-age?
Some CDN implementations use first-match-wins parsing rather than RFC-compliant comma-merging. If max-age appears before no-cache in the merged directive string, such CDNs may treat the response as freely cacheable. Always emit your most restrictive directives first, and confirm the CDN’s documented behaviour for conflicting directives.
Related
- Cache-Control Best Practices for REST APIs — how directive stacking manifests in JSON API responses and how to configure per-endpoint policies that survive proxy chains.
- How to Combine Cache-Control Directives Safely — a decision-flow walkthrough for composing directives without creating conflicts.
- Public vs Private Cache Scope — when to apply
privateat the origin versus at the edge, and what happens when a stacked header switches scope unexpectedly. - Mapping Vary Headers to Edge Routing — how CDNs generate cache keys from
Vary, and how stackedVaryfields interact with stackedCache-Controlto fragment or collapse cache storage. - How CDN Cache Keys Are Generated — the full picture of what an edge cache stores per-variant, which informs why normalising
Cache-Controlbefore the CDN boundary matters.