Understanding HTTP Cache Hierarchy
TL;DR: HTTP caching is a multi-tier architecture. Browser (private), CDN/proxy (shared), and origin each evaluate Cache-Control independently — no layer inherits state from another. Every directive either targets private caches, shared caches, or both. Misconfiguring one tier quietly corrupts all downstream behavior.
Cache-Control: public, s-maxage=86400, max-age=3600, stale-while-revalidate=300
Cache-Control directives independently. Shared caches honour s-maxage and public; browsers honour max-age and ignore s-maxage. Origin has no visibility into upstream cache state.Mechanism & RFC 9111 Alignment
RFC 9111 defines HTTP caching as a system of independent stores with no coordination protocol between them. Section 1 states that a cache “stores responses to reduce the number of requests and the perceived latency.” Each cache makes its own decision about whether a stored response is fresh, stale, or absent.
When a client initiates a request, the processing sequence at each tier is:
- Browser cache — checks memory and disk stores for a representation matching the request URL. If a fresh entry exists, it is returned immediately with no network contact. If stale, the browser sends a conditional request using freshness and validation mechanics.
- CDN / shared cache — on a browser miss, the nearest CDN PoP receives the request. It evaluates
Cache-Control,Vary, and conditional headers against its own stored entries. A CDN hit returns a cached response and updates theAgeheader to reflect time in cache. A CDN miss forwards the request to origin. - Origin — the authoritative source. Only reached on a CDN miss or explicit bypass. Returns the canonical response, sets freshness directives, and emits validators (
ETag,Last-Modified).
Crucially, shared caches do not inherit browser cache metadata, and origin has no visibility into what is stored at the CDN. Every tier must be configured to behave correctly in isolation. The baseline request delegation model is covered in Core Caching Fundamentals & HTTP Lifecycle.
On a stale entry, shared caches forward conditional requests using If-None-Match or If-Modified-Since. The origin returns 304 Not Modified when the stored copy is still valid — no body transfer, bandwidth preserved, CDN freshness timer resets.
Scope & Precedence
RFC 9111 Section 5.2.2 establishes explicit override rules governing which directives apply to which tier. Getting this wrong causes caches to either store content they should not, or refuse to cache content they safely could.
| Directive | Applies to | Ignored by | Overrides |
|---|---|---|---|
max-age |
All caches (shared + private) | Nothing | Expires header |
s-maxage |
Shared caches only (CDN, reverse proxy) | Browsers | max-age for shared caches, Expires |
public |
Shared caches | Private caches | Implicit non-cacheability (e.g., Authorization header) |
private |
All caches — blocks shared storage | — | s-maxage (shared caches must not store) |
no-store |
All caches | Nothing | All freshness directives at all tiers |
no-cache |
All caches | Nothing | Fresh serving without revalidation |
immutable |
Browsers (within max-age window) |
CDNs (generally) | Conditional request on reload |
Precedence chain for shared caches (CDNs, reverse proxies):
no-store— unconditional prohibition; nothing is stored regardless of other directives.private— blocks shared caching even whens-maxageis present.s-maxage— overridesmax-agefor freshness calculation. Absents-maxage, shared caches fall back tomax-age.public— explicitly grants shared cacheability; overrides implicit restrictions (e.g.,Authorizationheader presence).
Precedence chain for browsers (private caches):
no-store— nothing stored.max-age— primary freshness directive.Expiresis used only whenmax-ageis absent.no-cache— stored but must revalidate on every use.immutable— suppresses conditional requests within themax-agewindow.
For an in-depth analysis of shared caching scope, see Public vs Private Cache Scope. For the interaction between s-maxage and max-age, see Mastering max-age and s-maxage Directives.
Implementation Patterns
1. Fingerprinted static assets — maximum caching at all tiers
Content-hashed assets (filenames include a hash of the file content) never change at the same URL. Both browser and CDN can cache indefinitely:
Cache-Control: public, max-age=31536000, immutable
immutable eliminates browser conditional requests during the max-age window. The browser trusts that the resource at this URL will never change and skips the If-None-Match roundtrip on reload. CDNs cache for one year. When content changes, the URL changes, so stale cache entries are never served to new requests.
2. Shared API responses — CDN caches, browser always validates
For frequently updated non-personalized API endpoints, keep CDN latency gains while ensuring browser users see fresh data:
Cache-Control: public, s-maxage=60, max-age=0, stale-while-revalidate=120, proxy-revalidate
CDNs cache for 60 seconds, then serve stale for up to 120 more while background-refreshing. Browsers always go to the CDN (or origin on miss) — max-age=0 means no browser caching. proxy-revalidate prevents shared caches from serving stale indefinitely once both windows expire.
3. Personalized or authenticated content — browser only
User-specific responses must never enter a shared cache:
Cache-Control: private, no-cache
Vary: Cookie, Authorization
private prevents CDN storage. no-cache forces browser revalidation on every use. Vary on Cookie and Authorization also prevents CDN from storing a variant (redundant when private is set, but explicit). Note that stale-while-revalidate has no practical effect with private, no-cache — those directives already require per-request validation.
4. CDN-browser split TTL — tiered freshness control
Where CDN freshness and browser freshness need independent lifetimes:
Cache-Control: public, s-maxage=86400, max-age=3600, stale-while-revalidate=300
CDN serves for 24 hours. Browser caches for 1 hour. After s-maxage expires at the CDN, the 300-second stale-while-revalidate window means the CDN serves stale asynchronously while fetching fresh content — eliminating latency spikes at TTL boundaries.
5. Zero-TTL with background refresh — news/data feeds
Endpoints where data changes unpredictably but a few seconds of staleness is acceptable:
Cache-Control: public, s-maxage=0, stale-while-revalidate=30
s-maxage=0 means the CDN must revalidate on every request — but stale-while-revalidate=30 extends the window where the CDN can serve stale while revalidating asynchronously. In practice, the CDN serves the cached copy instantly and refreshes in background. This eliminates per-request origin load while keeping the data within 30 seconds of freshness.
Server & CDN Configuration
Nginx
# Fingerprinted static assets — immutable at browser, long CDN TTL
location ~* \.(js|css|woff2|avif|webp)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# API responses — CDN caches 60 s, browser always validates
location /api/ {
add_header Cache-Control "public, s-maxage=60, max-age=0, stale-while-revalidate=120, proxy-revalidate";
}
# Authenticated routes — browser only, always revalidate
location /account/ {
add_header Cache-Control "private, no-cache";
}
Apache
Header set Cache-Control "public, max-age=31536000, immutable"
Header set Cache-Control "public, s-maxage=60, max-age=0, stale-while-revalidate=120, proxy-revalidate"
Header set Cache-Control "private, no-cache"
Cloudflare
Cloudflare reads s-maxage from the origin response and uses it as the Edge TTL automatically. To enforce per-path policies independently of what origin emits, use Cache Rules in the dashboard (Rules → Cache Rules) or a Cloudflare Worker:
export default {
async fetch(request, env) {
const url = new URL(request.url);
const response = await fetch(request);
const newResponse = new Response(response.body, response);
if (url.pathname.startsWith("/api/")) {
newResponse.headers.set(
"Cache-Control",
"public, s-maxage=60, max-age=0, stale-while-revalidate=120"
);
} else if (url.pathname.startsWith("/account/")) {
newResponse.headers.set("Cache-Control", "private, no-cache");
}
return newResponse;
},
};
For Fastly, use VCL set beresp.ttl to control edge TTL and set beresp.grace to extend stale serving — these map to s-maxage and stale-while-revalidate semantics respectively.
Interaction with Related Directives
The cache hierarchy does not operate in isolation — several directives modify how each tier interacts with adjacent layers:
Vary — instructs caches to maintain separate stored variants per request header value (e.g., Vary: Accept-Encoding creates separate compressed and uncompressed variants). Each CDN PoP applies Vary independently. A Vary mismatch (requesting gzip when only br is cached) triggers a miss even if the content is otherwise identical. See Mapping Vary Headers to Edge Routing for CDN-specific behavior.
ETag and Last-Modified — validators stored alongside cached entries. When freshness expires, caches use these to send conditional requests (If-None-Match, If-Modified-Since) rather than fetching the full body. A 304 Not Modified from origin resets the freshness timer without transferring a payload. Ensure ETag values are consistent across all origin nodes — load-balanced origins with node-specific ETags will cause unnecessary full fetches.
no-transform — instructs intermediaries not to modify the response body. Relevant when CDN image optimization pipelines or compression middleware strip or alter Content-Encoding. Without no-transform, a CDN may convert an image format or recompress a body and serve a modified version, breaking fingerprint-based integrity checks.
must-revalidate / proxy-revalidate — activate after max-age or s-maxage expires respectively. Without these, some cache implementations may serve stale content under error conditions (origin unreachable). With them, the cache must return a 504 Gateway Timeout rather than serve a stale response once freshness and any stale window has elapsed.
Misaligned directives across layers cause the bottlenecks described in The Complete HTTP Request Lifecycle.
Verification Workflow
Step 1 — Confirm origin emits the correct directives.
curl -sI https://example.com/asset.js | grep -i 'cache-control\|etag\|last-modified'
Verify the full directive set is present before testing downstream behavior. A missing s-maxage here means the CDN will not cache regardless of other configuration.
Step 2 — Bypass CDN to fetch directly from origin.
curl -sI -H "Cache-Control: no-cache" https://example.com/asset.js \
| grep -iE 'cache-control|age|cf-cache-status|x-cache'
Most CDNs (Cloudflare, Fastly) treat a client no-cache request as a bypass — the origin response is returned directly and re-stored at the edge. This lets you verify the raw origin headers before CDN rewriting.
Step 3 — Confirm the CDN is storing and serving from cache.
Make two consecutive requests and compare Age and cache-status headers:
# First request — cold miss
curl -sI https://example.com/asset.js | grep -iE 'age|cf-cache-status|x-cache'
# Second request — should be a hit
curl -sI https://example.com/asset.js | grep -iE 'age|cf-cache-status|x-cache'
On a CDN hit, Age increases between requests and the status header shows HIT:
CF-Cache-Status: HIT(Cloudflare)X-Cache: HIT from <pop>(Varnish, Nginx, Fastly)X-Cache-Status: HIT(Nginxproxy_cache)
Age: 0 on the second request typically means the CDN just fetched from origin — either a miss or the CDN is not caching this path.
Step 4 — Calculate remaining TTL and verify freshness math.
Remaining TTL = max-age - Age (browser layer)
Remaining TTL = s-maxage - Age (CDN layer)
If Age exceeds s-maxage, the CDN is serving stale. Check whether stale-while-revalidate is set — the CDN may be in the stale-serving window intentionally.
Step 5 — Verify browser caching in DevTools.
Open Chrome DevTools → Network tab. Request the asset twice. In the Size column:
(memory cache)— served from browser memory, withinmax-agewindow(disk cache)— served from browser disk cache, withinmax-agewindow304status — browser sent conditional request aftermax-ageexpired; origin or CDN confirmed unchanged200from network — full fetch;max-ageexpired and content changed, or caching is disabled
For step-by-step diagnostic procedures, see How HTTP Caching Actually Works Step by Step.
Failure Modes & Gotchas
-
privatesilently neutralizess-maxage. If a CDN receivesCache-Control: private, s-maxage=86400, RFC 9111 requires the shared cache to discard the response — it must not be stored. Thes-maxagevalue is completely irrelevant. This is common when personalized responses are promoted to shared status without auditing existingprivatedirectives. -
CDN header rewriting exposes
s-maxageto browsers. Some CDN configurations strips-maxagefrom the downstreamCache-Controlheader and replace it with the equivalentmax-age. Browsers then receive amax-ageequal to the CDN’ss-maxage— often a much longer TTL than intended. Test what the CDN sends clients, not just what origin sends the CDN. -
no-storewith anETagis contradictory.no-storeprohibits any caching at any tier. AnETagon ano-storeresponse is ignored — there is no cache to send it back in a conditional request. The combination wastes header bytes and signals a confused caching model. -
Clock skew corrupts freshness calculations. Freshness is calculated from the
Dateresponse header. If origin clocks diverge across load-balanced nodes, theAgeandDatevalues become inconsistent. An NTP-synchronized cluster is a prerequisite for accurate TTL math. -
Vary: *makes any response uncacheable in shared caches.Vary: *means no two requests are considered equivalent — the CDN cannot reuse any stored entry.s-maxagehas no effect.Vary: *is appropriate only for truly uncacheable content; use specific header names (Accept-Encoding,Accept-Language) when possible. -
Missing
Ageheader breaks downstream freshness propagation. Some origin servers do not emitAge. Without it, a CDN receiving a response from an upstream CDN tier or shield node cannot accurately calculate how long the object has been in transit. Always emitAge: 0on fresh origin responses. -
immutablewithout versioned URLs causes stale content at browser.immutabletells browsers the response will never change during its freshness lifetime — no conditional requests will be sent. If the resource URL is not versioned (content-hashed), a file update on the server will not be visible to browsers untilmax-ageexpires. Useimmutableexclusively on fingerprinted URLs. -
stale-while-revalidatebrowser support is inconsistent. Firefox and Safari have historically had inconsistent support. Rely onstale-while-revalidatefor CDN-layer behavior where vendor support is confirmed; do not depend on it for browser-layer latency optimization.
FAQ
Does the browser cache share entries with the CDN?
No. Browser and CDN caches are completely independent stores. The browser checks its own local cache before making any network request. If the browser has a fresh entry, the CDN is never contacted. If the browser misses, the request goes to the CDN — which checks its own store independently. The CDN has no knowledge of what the browser holds, and the browser has no knowledge of CDN state.
Why does s-maxage have no effect in my browser’s DevTools?
Browsers are private caches and ignore s-maxage entirely. RFC 9111 Section 5.2.2.9 specifies that s-maxage applies only to shared caches. The browser’s freshness calculation uses only max-age (or Expires as a fallback). To control browser caching, use max-age.
Can private and s-maxage coexist?
Technically yes as header values, but private takes precedence and s-maxage is ignored by shared caches. RFC 9111 Section 5.2.2.7 states that a response with private “must not be stored by a shared cache.” The s-maxage value is irrelevant. Always audit for accidental private, s-maxage combinations — they indicate a configuration conflict.
How do I confirm a specific CDN PoP is caching correctly?
Send requests to the CDN’s debug endpoint or use curl with a header to force a specific PoP (Cloudflare: curl -H "CDN-Cache-Control: no-store" ... to bypass then re-enable). Check CF-Cache-Status, X-Cache, or equivalent vendor headers. An Age value greater than zero on consecutive requests confirms the PoP is serving from cache. Cloudflare also exposes CF-Ray to identify which PoP served the response.
What happens when CDN and browser max-age produce conflicting freshness windows?
There is no conflict — they govern different tiers. s-maxage controls CDN TTL independently of max-age, which controls browser TTL. The browser serves from its own cache while it is fresh regardless of what the CDN holds. Once the browser’s max-age expires, the browser contacts the CDN (which may itself serve from cache if its s-maxage has not expired). The two values are independent levers, not competing rules.
Related
- Cache Hit, Miss, and Bypass Mechanics covers the decision logic each cache tier applies to determine whether a stored entry is usable or must be bypassed.
- How CDN Cache Keys Are Generated explains how CDNs compute the lookup key for stored entries — a correct directive set is necessary but not sufficient for a cache hit if the key does not match.
- Header Stacking and Directive Precedence examines what happens when multiple conflicting
Cache-Controldirectives appear in the same response, including CDN-layer override behavior.