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-cache for authenticated routes.
  • The same response passes through an Nginx proxy that appends max-age=600 via add_header.
  • A CDN edge rule injects s-maxage=3600 unconditionally.

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.

Cache-Control directive precedence hierarchy A vertical priority ladder showing five levels: no-store at the top (absolute prohibition, all caches), then no-cache (storage permitted but revalidate always), then scope directives private and public (restricts or permits shared caches), then freshness directives max-age and s-maxage (sets TTL per tier), then extension directives stale-while-revalidate stale-if-error immutable at the bottom (modifies stale-period behaviour). P1 P2 P3 P4 P5 no-store Absolute prohibition — no storage at any cache tier Overrides every other directive when present no-cache Storage allowed; revalidate with origin before every use Applies to all cache tiers (browser + shared) private public Scope — restricts or permits shared-cache storage private wins over public when both present max-age s-maxage Freshness TTL — s-maxage overrides max-age for shared caches Browsers ignore s-maxage and use max-age stale-while-revalidate stale-if-error · immutable Extensions — modify behaviour after freshness expires Only active when higher-priority directives permit caching Higher priority

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 }
    }
  }'

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

  1. Open the Network tab and tick Disable cache.
  2. Reload the target resource and click the request row.
  3. In Response Headers, look for duplicate Cache-Control entries.
  4. Compare the Age response header value against the max-age or s-maxage value — a non-zero Age on a no-cache response confirms a CDN stacking error.
  5. 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

  1. add_header without always in Nginx. Nginx does not emit add_header directives on 4xx/5xx responses unless always is specified. Error pages pass through with whatever Cache-Control the upstream set — often nothing, which triggers heuristic caching in some CDNs.

  2. Framework middleware adding Cache-Control after 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.

  3. 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=60 will be appended to the merged string before a Cache Rule attempts to override it. Order matters — read your CDN’s pipeline documentation.

  4. Duplicate Vary fields compounding with stacked Cache-Control. If Vary is also stacked (multiple Vary: Accept-Encoding and Vary: Accept lines), the merged Vary set multiplies the number of cache keys alongside any directive conflicts. See Mapping Vary Headers to Edge Routing for how CDNs derive cache keys from Vary.

  5. stale-if-error ignored when no-store is present. Because no-store prevents any storage, there is no stored copy to serve under error conditions. The extension directive has no effect.

  6. HTTP/1.0 intermediaries and Pragma: no-cache. Legacy proxies do not parse Cache-Control. If your infrastructure includes HTTP/1.0-capable proxies, Pragma: no-cache must accompany no-cache or no-store to prevent intermediate caching. HTTP/1.1 and HTTP/2 clients ignore Pragma.

  7. Testing with curl only seeing one hop. curl reports 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.


Back to Cache-Control Directives & Header Combinations