The Complete HTTP Request Lifecycle
TL;DR: Every HTTP request traverses up to four distinct evaluation stages before a byte reaches the browser. Getting the Cache-Control response headers right at each stage eliminates redundant origin fetches, cuts latency, and prevents stale-content bugs.
# Canonical pattern: CDN-accelerated static asset
Cache-Control: public, max-age=31536000, immutable
How a Request Moves Through the Cache Stack
The diagram below shows the four evaluation stages and the decision that exits each one.
Stage 1 — Browser cache. The browser checks memory cache first (for in-page resources fetched in the same navigation), then disk cache. If a matching entry exists and its max-age has not elapsed, it is returned immediately with no network activity.
Stage 2 — CDN edge node. When the browser has no valid local entry, the request routes to the nearest CDN point of presence (PoP). The CDN independently evaluates Cache-Control, Vary, and any CDN-specific surrogate headers. A fresh CDN hit returns the stored body with an Age header indicating how many seconds the entry has been stored.
Stage 3 — Conditional validation. When a stored entry is stale, the CDN (or browser, on a direct-to-origin path) sends a conditional GET using If-None-Match (matching against the stored ETag) or If-Modified-Since (matching against Last-Modified). The origin returns either 304 Not Modified — no body, cache reuses what it has — or 200 OK with a fresh body. See Freshness vs Validation Models Explained for a full comparison of both validators.
Stage 4 — Origin fetch. Only when no layer holds a valid or revalidatable copy does the request reach the origin. The origin’s response headers govern how every upstream layer stores and future-serves the content.
Mechanism & RFC 9111 Alignment
RFC 9111 (which replaced RFC 7234 in June 2022) defines caching as a transparent extension of HTTP semantics. Caches are not passive stores — they are active participants that must evaluate freshness, apply directive restrictions, and forward requests according to normative rules.
Freshness calculation (RFC 9111 §5.1): A stored response is fresh when response_time + freshness_lifetime > current_time. The freshness_lifetime is determined by — in decreasing precedence — s-maxage (shared caches), max-age, or heuristic freshness (absent any explicit directive, some caches use a fraction of the Last-Modified age).
Storage eligibility (RFC 9111 §3): A response is eligible for caching only if it carries a status code understood to be cacheable by default (200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501) or if a Cache-Control directive explicitly permits storage.
no-store (RFC 9111 §5.2.2.5): Prohibits any cache from storing the response or the corresponding request. It is absolute — no other freshness directive can override it.
Authorization header restriction (RFC 9111 §3.5): Shared caches must not store a response to a request carrying an Authorization header unless the response explicitly includes public, must-revalidate, or s-maxage. Violating this rule causes shared caches to serve one user’s authenticated response to another.
Scope & Precedence
Which directive wins when several are present:
| Directive | Applies to | Overrides | Overridden by |
|---|---|---|---|
s-maxage=N |
Shared caches (CDN, proxy) | max-age, Expires |
no-store, private |
max-age=N |
All caches | Expires |
s-maxage (shared), no-store, private |
Expires |
All caches | Nothing newer | max-age, s-maxage |
no-store |
All caches | All freshness directives | Nothing — absolute |
private |
Shared caches | max-age for CDNs |
no-store |
no-cache |
All caches | None | no-store |
The key split between s-maxage and max-age is what enables the CDN-browser TTL split pattern: serve the CDN a long freshness window while constraining browsers to a short one, without using separate Vary partitions. See Mastering max-age and s-maxage for the full treatment.
Vary determines the cache key dimension. A CDN that ignores Vary will serve the wrong cached variant to clients that send different Accept-Encoding or Accept-Language values — a correctness violation, not just a performance concern. Mapping Vary Headers to Edge Routing covers CDN-specific Vary handling in detail.
Implementation Patterns
1. Versioned static assets with immutable
Cache-Control: public, max-age=31536000, immutable
immutable tells browsers not to issue a conditional GET during forced page reloads. Because the filename itself is fingerprinted (e.g. app.a1b2c3d4.js), the content at this URL will never change, so the conditional round-trip is unnecessary overhead. The immutable extension is defined in RFC 8246 and is honoured by all major browsers as of 2023.
2. Private, always-current user responses
Cache-Control: private, no-cache
private prevents shared caches from storing the response at all. no-cache requires the browser to revalidate with the origin before serving the stored copy. This combination is correct for session-scoped API responses, shopping carts, or any authenticated content. Do not add stale-while-revalidate here — it conflicts with the intent of no-cache and is meaningless on private content where no shared revalidation path exists.
3. Shared API response with background refresh
Cache-Control: public, s-maxage=300, stale-while-revalidate=60
The CDN serves the stored copy for 300 seconds, then enters a 60-second grace window during which it serves the stale copy while asynchronously revalidating with origin. Browsers are not subject to s-maxage and fall back to heuristic freshness unless max-age is also set. If you want browsers to cache too:
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60
4. CDN-browser TTL split for near-real-time content
Cache-Control: public, max-age=0, s-maxage=86400, must-revalidate
The CDN stores the response for 24 hours; browsers revalidate on every use. The must-revalidate directive (RFC 9111 §5.2.2.2) prohibits stale-serving even if the origin is temporarily unreachable — the CDN must return a 504 rather than serve expired content.
5. Authenticated API responses with Vary
Cache-Control: public, s-maxage=300
Vary: Cookie, Authorization
Vary: Cookie, Authorization partitions the CDN’s cache by authentication state so that one user’s session-specific response is never returned to another. Public vs Private Cache Scope explains when public is safe alongside authentication headers.
Server and CDN Configuration
Nginx
# Versioned static assets
location ~* "\.[a-f0-9]{8,}\.(js|css|woff2)$" {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# API: CDN caches, browsers revalidate
location /api/ {
add_header Cache-Control "public, s-maxage=300, max-age=0, must-revalidate";
add_header Vary "Accept-Encoding, Authorization";
}
# Private authenticated content
location /account/ {
add_header Cache-Control "private, no-cache";
}
Apache
# Versioned assets
Header set Cache-Control "public, max-age=31536000, immutable"
# API endpoints
Header set Cache-Control "public, s-maxage=300, max-age=0, must-revalidate"
Header set Vary "Accept-Encoding, Authorization"
Cloudflare (via _headers file or Page Rule)
# _headers file — place in site root
/static/*
Cache-Control: public, max-age=31536000, immutable
/api/*
Cache-Control: public, s-maxage=300, max-age=0, must-revalidate
Vary: Accept-Encoding, Authorization
Cloudflare’s CF-Cache-Status response header reports HIT, MISS, EXPIRED, BYPASS, REVALIDATED, or UPDATING — use it to confirm Cloudflare is storing and serving your responses as expected. BYPASS means a request or response property prevented caching (e.g. a Set-Cookie header or a private directive).
Interaction with Related Directives
stale-while-revalidate (RFC 5861): Extends the window during which a CDN may serve stale content while asynchronously fetching a fresh copy. Combine with s-maxage for shared caches and with max-age for browsers. Avoid combining with no-cache — they are mutually exclusive in intent.
stale-if-error (RFC 5861): Permits serving stale content when the origin returns 5xx or is unreachable. A safety net for graceful degradation, not a substitute for monitoring.
ETag and Last-Modified: These response headers enable conditional validation. Without them, an expired cache entry can only be replaced by a full 200 fetch — there is no 304 shortcut. Map ETag values to content hashes so all origin nodes return identical validators; a load-balanced fleet with per-node ETag generation will force unnecessary full fetches. For a detailed treatment, see Cache Hit, Miss and Bypass Mechanics.
immutable and HTTP/2: HTTP/2 persistent connections mean browsers send many requests in parallel before the previous responses arrive. Without immutable, browsers may issue speculative conditional GETs for assets they cached in the previous page load. immutable eliminates that round-trip entirely.
Protocol version does not affect Cache-Control semantics — HTTP/2 and HTTP/3 change framing and multiplexing but the directive behaviour defined in RFC 9111 is identical across versions. For protocol-version-specific caching behaviour, see Understanding HTTP 1.1 vs HTTP/2 Caching Rules.
Verification Workflow
Use this procedure to confirm each stage of the lifecycle is behaving correctly.
Step 1 — Establish a fresh-miss baseline.
curl -sI https://example.com/static/app.a1b2c3d4.js \
| grep -iE 'http/|cache-control|age|x-cache|cf-cache-status|vary|etag|last-modified'
On the first request, expect Age: 0 (or no Age header) and CF-Cache-Status: MISS (or X-Cache: MISS). This confirms the CDN fetched from origin.
Step 2 — Confirm a CDN hit.
Wait two seconds and repeat the same curl. The Age header value should be ≥ 2 and CF-Cache-Status should read HIT. If Age is still 0, the CDN is not caching — inspect the Cache-Control response and check for Set-Cookie or Authorization response headers that may be suppressing storage.
Step 3 — Test conditional validation.
# Capture the ETag from the previous response, then send a conditional request
ETAG=$(curl -sI https://example.com/api/data | grep -i etag | awk '{print $2}' | tr -d '\r')
curl -sI -H "If-None-Match: ${ETAG}" https://example.com/api/data \
| head -1
Expect HTTP/2 304. A 200 response means the origin is either not setting ETag or generating a new value on every request — check that all load-balanced nodes compute the same content hash.
Step 4 — Browser DevTools verification.
- Open DevTools → Network tab. Enable “Disable cache” and reload to force a fresh fetch.
- Re-enable caching and reload. The “Size” column should show
(disk cache)or(memory cache)for cached resources. - Inspect Response Headers: verify
Ageis present on CDN-served responses and thatCache-Controlmatches what your server config emits. - Check
Varyagainst the actual request headers sent — any header listed inVarythat varies between requests creates a separate cache entry. AVary: User-Agentfrom a misconfigured origin will fragment the CDN’s cache into thousands of entries.
Step 5 — Verify immutable is suppressing conditional requests.
On a hard reload (Ctrl+Shift+R / Cmd+Shift+R), fingerprinted assets with immutable should still appear as cache hits (no network request) in Chrome DevTools. Without immutable, a hard reload triggers conditional GETs for every cached asset.
Failure Modes & Gotchas
-
Set-Cookieon API responses disables CDN caching. Cloudflare and most CDNs refuse to cache any response that sets a cookie, even ifCache-Control: public, s-maxage=300is present. Move session management toHttpOnlycookies on a separate auth subdomain, or stripSet-Cookieat the CDN edge for publicly-cacheable endpoints. -
Vary: *makes a response uncacheable. RFC 9111 §4.1 states that a cache must not store or use a stored response when theVaryfield value is*. This is occasionally emitted by frameworks trying to disable caching — butno-storeis the correct directive for that purpose. -
Per-node
ETaggeneration breaks304on load-balanced origins. If each application server generatesETagfrom its in-process state rather than a shared content hash, a conditional request routed to a different node than the original response will receive a200instead of304. Use content hashes derived from the response body, computed identically across all nodes. -
max-age=0withoutmust-revalidateallows opportunistic stale serving. RFC 9111 §4.2.4 permits caches to serve stale content under certain disconnected conditions unlessmust-revalidateorproxy-revalidateis set. Always pairmax-age=0withmust-revalidatewhen stale serving is unacceptable. -
CDN stripping
Varybreaks multi-variant caching. Some CDNs stripVary: Accept-Encodingto simplify cache key management. This means compressed and uncompressed responses share a single cache entry and one variant may be served to clients that requested the other. VerifyVaryis preserved end-to-end with thecurlworkflow above. -
privatedirective ignored by misconfigured reverse proxies. Corporate proxies and some older CDN configurations do not always honourprivate. Pairprivatewithno-storefor genuinely sensitive content: the redundancy ensures at least one directive triggers compliant behaviour. -
immutablenot honoured in Firefox on HTTP (non-HTTPS). RFC 8246 statesimmutableis only respected over secure connections. Ensure your origin and CDN serve assets over HTTPS forimmutableto take effect.
FAQ
Does HTTP/2 or HTTP/3 change how Cache-Control directives work?
No. Cache-Control semantics are defined at the HTTP semantics layer (RFC 9110/9111), which is protocol-version agnostic. HTTP/2 and HTTP/3 change framing and multiplexing but not how max-age, s-maxage, no-store, or other directives are interpreted.
When does a CDN serve a stale response instead of fetching from origin?
A CDN serves stale content when the stale-while-revalidate window is active (the entry is expired but within the extension window) or when stale-if-error is set and the origin is returning 5xx errors. Without those directives, a CDN must revalidate or fetch fresh on expiry.
Why does s-maxage not affect browser caches?
RFC 9111 Section 5.2.2.10 defines s-maxage as a directive for shared caches only. Browsers are private caches and must ignore s-maxage, falling back to max-age or heuristic freshness instead.
What is the difference between a 200 with Age header and a 304 Not Modified?
A 200 with an Age header means an intermediary (CDN or proxy) returned a stored copy; Age indicates how many seconds old it is. A 304 Not Modified is a conditional validation response — the origin confirmed the stored body is still current, so the cache reuses it without re-transmitting the body.
Can no-cache and stale-while-revalidate be combined?
No. no-cache requires revalidation before every use, so max-age=0, no-cache already mandates a round-trip on each request. Adding stale-while-revalidate contradicts that intent and the extension window is effectively zero.
Related
- Cache Hit, Miss and Bypass Mechanics explains how each outcome is determined and what the
AgeandX-Cacheheaders mean in practice. - How to Combine Cache-Control Directives Safely covers the exact interaction rules when stacking multiple directives on one response.
- Cache-Control Directives & Header Combinations is the reference section for every directive covered above, with production-tested header recipes.
- How CDN Cache Keys Are Generated details how CDNs construct the lookup key that determines whether a request is a hit or miss.