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
HTTP cache hierarchy: browser private cache, CDN shared cache, origin server Diagram showing three tiers. Left: Browser (private cache), governed by max-age and immutable. Centre: CDN / Reverse Proxy (shared cache), governed by s-maxage, public, stale-while-revalidate. Right: Origin Server (authoritative source). Arrows show request flow left to right on miss, and response flow right to left. Each tier is annotated with the directives it respects. Browser (private cache) CDN / Proxy (shared cache) Origin Server (authoritative source) max-age immutable must-revalidate no-store private cache only ignores s-maxage s-maxage public / private stale-while-revalidate proxy-revalidate shared cache — all PoPs s-maxage overrides max-age Sets all directives Responds to 304 revalidation requests no visibility into upstream cache state miss miss 200 OK + headers cached response HIT: serves from disk/memory HIT: serves from edge cache MISS: generates fresh response Each tier evaluates Cache-Control independently — no shared state between layers
The three tiers of HTTP caching. Each evaluates 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:

  1. 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.
  2. 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 the Age header to reflect time in cache. A CDN miss forwards the request to origin.
  3. 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):

  1. no-store — unconditional prohibition; nothing is stored regardless of other directives.
  2. private — blocks shared caching even when s-maxage is present.
  3. s-maxage — overrides max-age for freshness calculation. Absent s-maxage, shared caches fall back to max-age.
  4. public — explicitly grants shared cacheability; overrides implicit restrictions (e.g., Authorization header presence).

Precedence chain for browsers (private caches):

  1. no-store — nothing stored.
  2. max-age — primary freshness directive. Expires is used only when max-age is absent.
  3. no-cache — stored but must revalidate on every use.
  4. immutable — suppresses conditional requests within the max-age window.

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.

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 (Nginx proxy_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, within max-age window
  • (disk cache) — served from browser disk cache, within max-age window
  • 304 status — browser sent conditional request after max-age expired; origin or CDN confirmed unchanged
  • 200 from network — full fetch; max-age expired 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

  1. private silently neutralizes s-maxage. If a CDN receives Cache-Control: private, s-maxage=86400, RFC 9111 requires the shared cache to discard the response — it must not be stored. The s-maxage value is completely irrelevant. This is common when personalized responses are promoted to shared status without auditing existing private directives.

  2. CDN header rewriting exposes s-maxage to browsers. Some CDN configurations strip s-maxage from the downstream Cache-Control header and replace it with the equivalent max-age. Browsers then receive a max-age equal to the CDN’s s-maxage — often a much longer TTL than intended. Test what the CDN sends clients, not just what origin sends the CDN.

  3. no-store with an ETag is contradictory. no-store prohibits any caching at any tier. An ETag on a no-store response 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.

  4. Clock skew corrupts freshness calculations. Freshness is calculated from the Date response header. If origin clocks diverge across load-balanced nodes, the Age and Date values become inconsistent. An NTP-synchronized cluster is a prerequisite for accurate TTL math.

  5. Vary: * makes any response uncacheable in shared caches. Vary: * means no two requests are considered equivalent — the CDN cannot reuse any stored entry. s-maxage has no effect. Vary: * is appropriate only for truly uncacheable content; use specific header names (Accept-Encoding, Accept-Language) when possible.

  6. Missing Age header breaks downstream freshness propagation. Some origin servers do not emit Age. 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 emit Age: 0 on fresh origin responses.

  7. immutable without versioned URLs causes stale content at browser. immutable tells 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 until max-age expires. Use immutable exclusively on fingerprinted URLs.

  8. stale-while-revalidate browser support is inconsistent. Firefox and Safari have historically had inconsistent support. Rely on stale-while-revalidate for 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.


Back to Core Caching Fundamentals & HTTP Lifecycle