How to Combine Cache-Control Directives Safely
Problem Statement
Stacking multiple Cache-Control directives in a single header without understanding their precedence relationships produces undefined, implementation-dependent caching behavior. A header like Cache-Control: public, max-age=86400, no-cache looks intentional but is self-contradictory: no-cache forces revalidation before every use, making the max-age=86400 freshness window unreachable. This guide shows exactly which combinations are safe, which violate RFC 9111, and how to verify correct behavior end-to-end.
Prerequisite Concepts
Before working through the steps below, make sure you understand these foundational topics:
- Mastering
max-ageands-maxageDirectives — how the two-tier TTL model splits browser and CDN freshness lifetimes and whys-maxageoverridesmax-agefor shared caches. no-cachevsno-store: When to Use Each — the critical semantic difference between forcing revalidation on every request versus prohibiting storage entirely.- Public vs Private Cache Scope — which directive authorises shared caches and which restricts a response to the end-user’s private cache.
Directive Interaction Diagram
The diagram below shows how RFC 9111 resolves the most common directive groupings across browser and shared-cache tiers.
Step-by-Step Resolution
Step 1 — Identify the resource type and caching intent
Before writing a single directive, answer three questions:
- Who may store this response? Private user data must stay in the browser only (
private). Assets safe for CDN storage needpublicors-maxage. - How long before the response goes stale? Content-hashed static assets can be
immutablefor a year. API responses that update every minute need a shorts-maxage. - What should happen when a cached copy is stale? A hard
no-cacherequires a server round-trip;stale-while-revalidateallows background refresh while serving the stale copy.
Step 2 — Select a safe directive combination
The table below maps resource types to tested, RFC-compliant directive sets.
| Resource type | Safe Cache-Control value |
Reasoning |
|---|---|---|
| Content-hashed static asset (JS, CSS, images) | public, max-age=31536000, immutable |
Filename changes on deploy; one-year TTL is safe for all tiers |
| Dynamic HTML (shared, updates frequently) | public, s-maxage=60, stale-while-revalidate=300 |
CDN caches for 60 s; background refresh within the 300 s stale window |
| API JSON (authenticated, shared by role) | public, s-maxage=30, no-cache |
CDN can cache 30 s; browsers must revalidate every request |
| User-specific data (session, profile) | private, no-cache |
Browser stores for reuse but validates on every request; CDN bypasses |
| Sensitive or regulated data | no-store |
No storage at any layer; never paired with max-age or s-maxage |
Hard rules from RFC 9111:
- Never pair
publicwithprivate— they are mutually exclusive scope directives. - Never pair
no-storewithmax-ageors-maxage—no-storeprohibits the storage that TTL directives depend on. - Never pair
no-cachewith a non-zeromax-ageexpecting themax-agewindow to apply —no-cachewins and themax-agefreshness interval is never exercised. If you need a short browser TTL alongside a longer CDN TTL, usemax-agefor the browser ands-maxagefor the CDN. stale-while-revalidatecombined withno-cacheis contradictory —no-cacheprevents serving any stored response without revalidation, so the stale-serving permission is never used.
Step 3 — Configure the origin to emit the correct header
Nginx — per-location directive:
location ~* \.(js|css|png|svg|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /api/ {
add_header Cache-Control "public, s-maxage=30, no-cache";
}
Apache — using mod_headers:
Header set Cache-Control "public, max-age=31536000, immutable"
Header set Cache-Control "public, s-maxage=30, no-cache"
Cloudflare Cache Rule (transform rule on response headers, Dashboard → Rules → Transform Rules → Modify Response Header):
Field: Cache-Control
Action: Set to
Value: public, s-maxage=60, stale-while-revalidate=300
Apply this rule with a matching expression like (http.request.uri.path matches "^/api/") for API routes, or leave the origin header intact for static assets and let Cloudflare inherit it.
Step 4 — Verify end-to-end with curl
Run two successive requests and inspect the Age, X-Cache, and Cache-Control values:
# First request — should miss the CDN cache
curl -sI https://example.com/api/data \
| grep -iE "^(cache-control|age|x-cache|cf-cache-status):"
# Second request — Age should be rising if the CDN is caching
curl -sI https://example.com/api/data \
| grep -iE "^(cache-control|age|x-cache|cf-cache-status):"
Expected output for public, s-maxage=60, stale-while-revalidate=300:
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
Age: 3
CF-Cache-Status: HIT
A rising Age value on the second call confirms the shared cache is storing and serving the response. An Age stuck at 0 with a MISS or BYPASS status means the directive is preventing CDN storage — check for conflicting headers such as Set-Cookie (which implicitly makes responses uncacheable by shared caches unless public overrides it) or a CDN policy rule overriding the origin value.
To force cache bypass and test the true origin header without CDN interference:
curl -sI -H "Cache-Control: no-cache" https://example.com/api/data \
| grep -iE "^(cache-control|age|x-cache|cf-cache-status):"
Step 5 — Confirm browser behavior with DevTools
- Open Chrome or Firefox, press F12, and switch to the Network tab.
- Tick Disable cache and perform a hard reload (Ctrl+Shift+R / Cmd+Shift+R). Inspect the Response Headers panel for the exact
Cache-Controlstring emitted by the server. This is the ground truth — confirm it matches what you configured in Step 3. - Untick Disable cache and reload normally. Check the Size column:
(disk cache)— the browser served the response from disk storage;max-ageis working.(memory cache)— the browser served from in-memory storage;max-ageis working.- A numeric byte count with
200 OK— the browser made a network request; either the TTL expired,no-cacheis forcing revalidation, or the response is not being stored.
- For resources with
no-cache, check the Status column. You should see304 Not Modifiedon subsequent requests, confirming the browser is sending conditional requests withIf-None-MatchorIf-Modified-Sinceand the server is validating correctly. A200 OKinstead of304means the server is not sending ETags orLast-Modifiedheaders, and the freshness vs validation model is falling back to full re-fetches.
Expected Output / Verification
A correctly configured response for each resource class looks like this:
Content-hashed static asset (one-year immutable):
HTTP/2 200
Cache-Control: public, max-age=31536000, immutable
ETag: "abc123def456"
Age: 0
CF-Cache-Status: MISS
On the second request, Age rises and CF-Cache-Status becomes HIT. In DevTools, the Size column shows (disk cache) for subsequent page loads.
Dynamic API response (short CDN TTL, stale-while-revalidate):
HTTP/2 200
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
Age: 22
CF-Cache-Status: HIT
Within the 60 s freshness window, Age increments and CF-Cache-Status is HIT. Between 60 s and 360 s (s-maxage + stale-while-revalidate), Cloudflare serves the stale copy while triggering a background revalidation. After 360 s, the entry is evicted and the next request becomes a MISS.
User-specific response (private, no-cache):
HTTP/2 200
Cache-Control: private, no-cache
Vary: Cookie
ETag: "user42-v7"
No CDN should cache this. Confirm with:
curl -sI -b "session=abc" https://example.com/dashboard \
| grep -iE "^(cache-control|age|cf-cache-status):"
Expected: CF-Cache-Status: BYPASS (Cloudflare) or X-Cache: MISS (Fastly/Varnish). An Age header appearing on a private response is a misconfiguration — either the CDN policy is overriding the origin header or the route is missing authentication enforcement.
Edge Cases
-
CDN policy overrides silently drop origin directives. CloudFront Cache Policies, Cloudflare Cache Rules, and Fastly VCL can all override origin
Cache-Controlvalues. Always verify the header the CDN emits to clients (curlfrom an external network), not just what the origin emits. These take precedence over origin headers when configured, and the discrepancy is the most common source of “why isn’t this caching?” bugs. -
Set-Cookieresponses are not stored by shared caches by default. RFC 9111 §7.3 states that a response with aSet-Cookieheader must not be stored in a shared cache unless the response explicitly carriespublic. If your API sets cookies and you want CDN caching, addpublicintentionally and confirm the cookie does not contain user-specific session state that would cause cache poisoning. -
HTTP/1.1 vs HTTP/2 proxies and legacy
Pragma: no-cache. Corporate intermediaries running HTTP/1.0-era proxy software may ignoreCache-Controlentirely and honour onlyPragma: no-cache. Sending bothPragma: no-cacheandCache-Control: no-storecovers both eras. Modern CDNs and browsers ignorePragmaentirely, so this is only relevant when supporting legacy enterprise networks. -
Missing
ETagorLast-Modifiedmakesno-cachewasteful.no-cacherequires the server to validate on every request, but if the origin does not return anETagorLast-Modifiedheader, the browser cannot send a conditional request — it receives a full200 OKinstead of a lightweight304 Not Modified. Check the header stacking and directive precedence cluster for guidance on ensuring validators are always present whenno-cacheis in use.
FAQ
Can I use no-cache and max-age together?
You can, but no-cache renders max-age irrelevant for freshness decisions — the cache must revalidate before every use regardless of the max-age value. If you want a long CDN TTL with mandatory browser revalidation, use s-maxage for the CDN and no-cache for browsers instead of pairing no-cache with max-age.
Does public override private in the same header?
No. RFC 9111 treats public and private as mutually exclusive cache-scope directives. Sending both in the same header is a protocol violation; behavior is implementation-defined and unpredictable across CDNs and browsers. Always send exactly one or neither.
Does no-store with max-age cause any harm?
no-store prohibits any form of storage, making max-age meaningless — a cache cannot honour a TTL for a response it is not allowed to store. The combination wastes header space and can confuse CDN logic that parses directives independently.
Will stale-while-revalidate work alongside no-cache?
No. no-cache requires revalidation before every use, which directly contradicts stale-while-revalidate’s permission to serve stale content during a background fetch. no-cache takes precedence and stale-while-revalidate has no effect. Remove one.
Related
- The what happens when
max-ageexpires page walks through the full revalidation sequence that fires after a freshness lifetime ends. - Header stacking and directive precedence covers how RFC 9111 resolves conflicts when multiple Cache-Control values appear on the same response, including multi-header merging rules.
- Understanding how CDN cache keys are generated explains why two identical
Cache-Controlheaders on different URLs can produce completely different CDN storage outcomes.