How HTTP Caching Actually Works Step by Step
Problem Statement
Engineers debugging cache behaviour — stale JSON payloads, unexpected 304 loops, or authenticated data leaking to the wrong user — often focus on network timing when the actual fault is in the evaluation sequence applied by each caching layer. HTTP caching follows a deterministic chain: browser lookup, freshness arithmetic, conditional validation, and origin fetch. Knowing exactly what happens at each step, in order, is the shortest path from a cache bug to a fix.
Prerequisite Concepts
Before working through the steps below, three concepts are load-bearing:
- Understanding HTTP Cache Hierarchy explains how browser, CDN edge, and origin each operate independently, without shared state. The tier structure determines which layer runs which step.
- Freshness vs Validation Models Explained covers how
max-ageand ETags determine whether a stored response can be used or must be revalidated — the foundation of steps 2 and 3 below. - Cache-Control Directives & Header Combinations maps the directive vocabulary (
private,public,no-cache,no-store,s-maxage) to the scenarios used throughout this page.
The Cache Evaluation Sequence
Every cacheable HTTP request travels through this sequence at each caching layer that handles it. The diagram below shows the full decision flow; the numbered steps that follow describe each branch in detail.
Step 1 — Browser cache lookup
The caching layer checks whether a stored response matches the incoming request. “Match” requires the URL, the request method, and any headers named in Vary to align with the stored entry’s key. A Vary: Accept-Encoding entry keyed on gzip will not match a request that sent no Accept-Encoding.
If a fresh match is found, it is served immediately — no network contact occurs. This is a cache hit. No Age header is synthesised for private browser cache hits; the browser tracks freshness internally using the stored Date and max-age values.
Step 2 — Freshness arithmetic
When a stored entry exists but its freshness must be evaluated:
Remaining Freshness = max-age − Age
max-age comes from the stored Cache-Control response directive. Age accumulates from the moment the response was placed into a shared cache; the CDN passes this value downstream so downstream layers know how stale the entry already is. If Remaining Freshness is positive, the entry is fresh and is served. If zero or negative, it is stale and must be validated or replaced.
Shared caches (CDNs) use s-maxage in place of max-age when present. Browsers ignore s-maxage entirely.
Step 3 — Conditional revalidation
A stale entry triggers a conditional request to the next upstream layer. The cache attaches:
If-None-Match: "<etag>"— when the stored response carried anETagIf-Modified-Since: <date>— when the stored response carriedLast-Modified
The origin (or intermediate shared cache) compares the validator against the current representation:
304 Not Modified— the stored body is still current. RFC 9111 Section 4.3.4 permits the304response to include updated header fields such asCache-ControlandETag, which replace the stored values, effectively extending the freshness window without retransmitting the body.200 OK— the resource has changed. The cache replaces the stored entry with the new response body and headers.
Step 4 — Origin fetch (no stored entry)
When no stored entry exists at any layer, the request reaches origin. The origin returns the authoritative response. Downstream layers store it according to the response’s Cache-Control directives (provided no-store is absent and the response is otherwise storable per RFC 9111 Section 3).
Step-by-Step Resolution
The following steps map directly to the evaluation sequence above. Run each curl command, inspect the output, then proceed to the next.
Step 1: Confirm origin headers are set correctly
curl -sI https://example.com/api/v1/resource \
| grep -iE 'cache-control|etag|last-modified|vary|age'
Expected for a public cacheable resource:
Cache-Control: public, max-age=300, s-maxage=600
ETag: "abc123"
Vary: Accept-Encoding
If Cache-Control is absent, caches may apply heuristic freshness (typically 10% of Last-Modified age) — unpredictable and unsafe for API responses.
Step 2: Observe Age accumulating at the CDN layer
Repeat the request twice, one second apart:
curl -sI https://example.com/static/bundle.js \
| grep -iE 'x-cache|cf-cache-status|age'
First response (CDN miss, populates cache):
CF-Cache-Status: MISS
Age: 0
Second response (CDN hit, entry is now 1–2 seconds old):
CF-Cache-Status: HIT
Age: 2
Age increments each second the entry sits in the shared cache. When Age reaches s-maxage, the CDN will revalidate.
Step 3: Test conditional revalidation
Extract the ETag from the first response, then send a conditional request:
ETAG=$(curl -sI https://example.com/data.json | grep -i etag | awk '{print $2}' | tr -d '\r')
curl -sI -H "If-None-Match: $ETAG" https://example.com/data.json | head -5
Expected output when the resource has not changed:
HTTP/2 304
etag: "abc123"
cache-control: public, max-age=300
A 304 with no body body confirms revalidation is working correctly. The stored body is reused; only headers are updated.
Step 4: Verify bypass for authenticated routes
curl -sI -H "Authorization: Bearer <token>" \
https://example.com/api/v1/user/profile \
| grep -iE 'cache-control|cf-cache-status|x-cache'
Correct output for a private authenticated endpoint:
Cache-Control: private, no-cache
CF-Cache-Status: BYPASS
If CF-Cache-Status: HIT appears here, the CDN is storing authenticated responses under a shared cache key — a data leakage risk. The fix is either private in Cache-Control, or a CDN-level bypass rule scoped to the route.
Step 5: Confirm browser-level hits in Chrome DevTools
Open Chrome DevTools, select the Network tab, reload the page without disabling the cache. Inspect the Size column:
(memory cache)— served from the in-process memory cache; very recent or frequently accessed entries(disk cache)— served from the on-disk browser cache; cross-session persistence- An actual byte count (e.g.
14.2 kB) — a real network fetch; check whether this should have been cached
Click the request and open the Response Headers panel. Confirm the Cache-Control values match the policy you set at the origin. Any middleware stripping or overwriting headers will be visible here.
Expected Output / Verification
A correctly configured caching stack produces these observable signals:
| Layer | Signal | Correct value |
|---|---|---|
| Origin | Cache-Control present on every response |
Yes — never absent on API or asset routes |
| CDN (first request) | CF-Cache-Status or X-Cache |
MISS or DYNAMIC |
| CDN (repeat request for public resource) | Same header | HIT |
| CDN (authenticated route) | Same header | BYPASS |
| CDN (stale entry) | Age value |
Equal to s-maxage or higher, triggering revalidation |
| Browser (repeat page load) | Size column | (disk cache) or (memory cache) for static assets |
| Revalidation response | HTTP status | 304 Not Modified with no body |
A 200 on every repeated request to the same URL indicates caching is not working. A HIT on an authenticated route indicates misconfigured cache scoping. An Age value greater than max-age on the final response indicates the CDN is not revalidating when it should.
Edge Cases
-
Browser vs CDN freshness diverge. A CDN configured with
s-maxage=600andmax-age=60will serve fresh responses to the browser for 60 seconds, but the CDN itself holds the entry for 600 seconds. A user who hard-refreshes bypasses the browser cache but still hits the CDN’s cached copy. Useno-cacheto force CDN-to-origin revalidation even when the CDN entry is fresh. -
HTTP/1.1 vs HTTP/2 conditional request format. Both protocol versions support
If-None-MatchandIf-Modified-Since. HTTP/2 multiplexes requests over a single connection, so multiple conditional requests can be inflight simultaneously — but the origin must still return a304per request. The evaluation logic is identical; only the wire format differs. See HTTP/1.1 vs HTTP/2 Caching Rules for protocol-level differences. -
Missing validators. When the origin returns neither
ETagnorLast-Modified, conditional revalidation is impossible. The cache must issue a full unconditional fetch every time the entry goes stale. Add ETags at the origin to enable bandwidth-efficient revalidation — typically a content hash of the response body. -
Service worker interception. Service workers intercept
fetchevents before the browser cache is consulted and can apply entirely independent storage logic. Acache.put()call inside a service worker bypasses HTTP caching semantics entirely. If DevTools shows(ServiceWorker)in the Size column, the response is coming from the service worker’s cache, not the HTTP cache. Auditcache.addAll()andfetch()handlers to confirm they respectno-storefor sensitive routes.
FAQ
Why does Age sometimes arrive larger than max-age?
This happens when the CDN considers the entry stale but serves it anyway under stale-while-revalidate. The directive stale-while-revalidate=N permits the cache to serve a stale response for up to N additional seconds while a background revalidation request is in flight. The Age value will exceed max-age during this window.
Can a 304 response update Cache-Control headers?
Yes. RFC 9111 Section 4.3.4 specifies that a 304 response may include updated header fields, including Cache-Control, ETag, and Vary. The cache must use the new values, replacing any previously stored field values. This is commonly used to extend TTLs without a full response body.
Why does CF-Cache-Status: DYNAMIC appear instead of HIT or MISS?
Cloudflare uses DYNAMIC to indicate that the response was not eligible for caching — either because Cache-Control: no-store or private was set, or because the route matched a “Cache Level: Bypass” rule. It is not a miss in the traditional sense; it means caching was explicitly skipped.
Related
- Cache-Control Best Practices for REST APIs covers how to scope
privateandno-cachecorrectly across API endpoints to prevent shared-cache leakage. - How to Calculate Cache Freshness Lifetime works through the
max-age − Agearithmetic in detail, including heuristic freshness whenCache-Controlis absent. - Using Vary: Accept-Encoding Without Fragmenting Cache explains how
Varycreates separate cache keys and how to prevent cache fragmentation under edge routing. - When Does a Browser Invalidate a Cached Resource describes the browser-specific triggers — navigation type, explicit reload, cache eviction — that cause the browser to bypass or discard a stored entry.