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:

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.

HTTP Cache Evaluation Sequence Decision flowchart showing how a caching layer processes an incoming HTTP request: check for a stored entry, evaluate freshness, revalidate if stale, or fetch from the next layer. Incoming request Stored entry exists? Fetch from origin No Store + serve response Entry is fresh? Yes Serve stored response Yes Send conditional request No Origin returns 304? Refresh headers, serve stored body Yes Replace stored entry, serve new body No ① Browser/CDN lookup ② Freshness check (max-age − Age) ③ Conditional validation Each layer runs this independently

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 an ETag
  • If-Modified-Since: <date> — when the stored response carried Last-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 the 304 response to include updated header fields such as Cache-Control and ETag, 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=600 and max-age=60 will 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. Use no-cache to 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-Match and If-Modified-Since. HTTP/2 multiplexes requests over a single connection, so multiple conditional requests can be inflight simultaneously — but the origin must still return a 304 per 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 ETag nor Last-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 fetch events before the browser cache is consulted and can apply entirely independent storage logic. A cache.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. Audit cache.addAll() and fetch() handlers to confirm they respect no-store for 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.


Back to Understanding HTTP Cache Hierarchy