Skip to content
Knowledge Hub

HTTP caching: who may store a response, and for how long? Intermediate

Estimated reading time: 10 min read

An online shop serves three kinds of responses through a CDN:

  • app.3f9a1c.js and product images: identical for everyone, changed only on deploy;
  • GET /products: the same list for every visitor, updated a few times an hour;
  • GET /account: name, address and orders of one logged-in user.

Every response passes through up to two caches: the browser (a private cache, one user) and the CDN (a shared cache, all users). Without explicit headers, each cache applies its own defaults.

The question: which headers let the CDN absorb product traffic, keep one user’s data out of shared caches, and make a deploy visible within seconds?

Situation Send Condition
Fingerprinted asset (app.3f9a1c.js) public, max-age=31536000, immutable Content change means a new file name
HTML entry page that references those assets no-cache + ETag Every use is validated; unchanged pages cost a 304
Shared API list behind a CDN public, s-maxage=60, stale-while-revalidate=30 60–90 s of staleness is acceptable
One user’s data private, max-age=60 or private, no-cache Never public; CDN must not store it
Secrets (statement PDF, one-time token) no-store Accept that every view hits the origin
Write with lost-update risk ETag on read, If-Match on write Answer 412 on mismatch

A stored response is fresh while its age is below its freshness lifetime (RFC 9111 §4.2), and is reused with no network call.

Once stale, a cache validates: it sends a conditional request, and the origin answers 304 Not Modified (keep your copy) or 200 with a new body.

Freshness saves round trips; validation saves bytes. Most policies combine both.

Directive Meaning (RFC 9111 §5.2.2 unless noted)
max-age=N Fresh for N seconds in any cache
s-maxage=N Overrides max-age for shared caches only; stale copies must be validated
no-cache May be stored, but must be validated before every reuse
no-store Must not be stored at all
private Shared caches must not store it; the browser may
public Any cache may store it, even if it would otherwise be forbidden (for example with Authorization)
must-revalidate Once stale, never served without validation; if the origin is unreachable, answer 504
immutable Will not change while fresh: skip conditional requests on reload (RFC 8246)
stale-while-revalidate=N May be served up to N s stale while revalidating in the background (RFC 5861)
stale-if-error=N May be served up to N s stale when the origin returns 500, 502, 503 or 504 (RFC 5861)

The common confusion: no-cache does not mean “do not cache”. It means “check with me first”. no-store is the one that forbids storage.

An ETag is an opaque version tag for one representation (RFC 9110 §8.8.3). Last-Modified is a date with one-second resolution, so two edits in the same second look identical.

Prefer ETag; when a request carries both, If-None-Match wins and If-Modified-Since is ignored (RFC 9110 §13.1.3).

If-None-Match uses weak comparison (W/"1" matches "1"); If-Match uses strong comparison, because it guards writes (RFC 9110 §13.1.1–13.1.2).

By default a cache keys on method and URI. Vary: Accept-Encoding adds a request header to the key: a gzip body is reused only for requests that also accept gzip (RFC 9111 §4.1). Different encodings are different representations, so they need different ETags (RFC 9110 §8.8.3.3).

Vary: Authorization is unnecessary: shared reuse across users is already forbidden by RFC 9110 §12.5.5. Vary: Cookie is technically correct but splits the cache per session; private is usually the clearer choice.

GET /products returns Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30 with a strong ETag. The browser revalidates every time; the CDN holds the list.

The CDN answers browser revalidations itself and refreshes its own copy with a bodyless 304
The CDN answers browser revalidations itself and refreshes its own copy with a bodyless 304Step 1/5: t = 0 s: both caches are empty. The CDN forwards GET /products; the API answers 200 with the ETag. Both caches store it.Browser cacheprivate, max-age=0CDNshared, s-maxage=60storedProduct APIETag "0WXD…"GET → 200 + ETag
CDN → Product APIGET → 200 + ETag
Step 1/5

t = 0 s: both caches are empty. The CDN forwards GET /products; the API answers 200 with the ETag. Both caches store it.

Time Browser CDN copy Origin traffic
0 s Stores, stale at once Stored, age 0 One full 200
20 s Sends If-None-Match Fresh: answers 304 itself None
75 s Gets the stale list immediately Stale, inside the 30 s window Background 304, no body
> 90 s with no traffic Waits Truly stale Blocking validation

A 304 must repeat ETag, Cache-Control, Vary and Date (RFC 9110 §15.4.5), so it also renews the stored copy’s freshness.

With no max-age, s-maxage or Expires, a cache may invent a lifetime for heuristically cacheable statuses: 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501 (RFC 9110 §15.1). RFC 9111 §4.2.2 suggests about 10% of the time since Last-Modified.

So a /products response last modified 10 days ago could be served for about a day without any check. 302 is not on the list; a cached 302 needs explicit headers (URL shortener). Always send Cache-Control.

RFC 9111 §3.5: a shared cache must not reuse a response to a request with Authorization unless the response has public, s-maxage or must-revalidate. Adding s-maxage to a per-user endpoint therefore lets the CDN serve it.

Cookies get no such protection. A cookie-session /account response with max-age=60 and no private may be stored by a CDN and returned to the next visitor.

The same tag prevents lost updates. The client echoes the ETag in If-Match; on mismatch the origin must not perform the method and answers 412 (RFC 9110 §13.1.1).

If-Match turns a silent lost update into a 412 that admin B must resolve
If-Match turns a silent lost update into a 412 that admin B must resolveStep 1/4: Both admins read product 42 and receive ETag "v7".timeGET /products/42ETag "v7"GET /products/42ETag "v7"Admin Asets 1,100,000Admin Bsets 990,000Product APIproduct 42
Step 1/4

Both admins read product 42 and receive ETag "v7".

  1. Both admins hold "v7"; the API is at "v7".
  2. A’s tag matches; the API writes and moves to "v8".
  3. B’s "v7" fails strong comparison; nothing is written, B gets 412 as a problem+json body.
  4. B re-reads "v8" and decides again.

If-Match checks a version; it does not deduplicate retried POSTs. That is the Idempotency-Key job.

Loading diagram…

  • Cache busting: build tools put a content hash in the file name. The HTML must be revalidated (no-cache) so a deploy points to new names at once; assets stay cached for a year.
  • CDN purge vs short TTL: purge is a vendor API, not part of HTTP, and reaches only the CDN. Browser copies live until their max-age ends. Keep browser max-age short and s-maxage longer; purge on urgent changes.
  • Unsafe methods (POST, PUT, DELETE) invalidate the target URI only in caches they pass through (RFC 9111 §4.4); other CDN nodes keep their copy.
  • no-store is not a security control: RFC 9111 §5.2.2.5 notes that compromised caches may ignore it. Sensitive data still needs TLS and authorization.
  • stale-while-revalidate needs traffic: background refresh starts only when a request arrives inside the window (RFC 5861 §3.1).
  • HTTP caches sit in front of the API; Redis sits behind it. A CDN miss still reaches the API, where cache-aside and stampede protection handle the hot keys.

Run the demo (Node 24, node:http only):

Terminal window
npx --prefix examples ts-node examples/src/http-caching/server.ts
bash examples/src/http-caching/curl.sh # in another terminal

Trimmed output from one run (hashes differ per data):

== 1. First GET: 200 with ETag
HTTP/1.1 200 OK
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30
ETag: "0WXDeSn-DbFvuJpn"
Vary: Accept-Encoding
== 2. Conditional GET with If-None-Match: "0WXDeSn-DbFvuJpn"
HTTP/1.1 304 Not Modified
ETag: "0WXDeSn-DbFvuJpn"
== 3. Same URL, Accept-Encoding: gzip
HTTP/1.1 200 OK
ETag: "uVgFOdYlm87-bwSG"
Content-Encoding: gzip
== 4. Identity ETag sent with Accept-Encoding: gzip -> full 200
== 5. admin A If-Match "v7" -> 200, admin B If-Match "v7" -> 412

Step 4 shows why Vary and per-encoding ETags go together: the identity tag does not validate the gzip representation, so the server sends the full body instead of a wrong 304.

Long freshness

  • Zero round trips: the cache answers alone.
  • The CDN absorbs read spikes before they reach the API.
  • With stale-if-error, a short origin outage stays invisible.

Validate every time

  • Changes appear only after the lifetime ends, unless the URL changes.
  • Browser copies cannot be purged.
  • A wrong public on personal data leaks it to other users.

Validation (no-cache + ETag) is the reverse: always correct, but every use costs a round trip, and computing the ETag may still need the database.

  1. Every response sends an explicit Cache-Control; no endpoint relies on heuristic freshness.
  2. Personal responses carry private (or no-store), including cookie-session endpoints and error pages that echo user data.
  3. Static assets have content hashes in their names and immutable; HTML entry pages use no-cache.
  4. Responses whose body depends on a request header list it in Vary, and each representation has its own ETag.
  5. 304 responses repeat ETag, Cache-Control and Vary.
  6. Writes on shared resources require If-Match and return 412 with a problem body.
  7. A runbook lists purge commands and the worst-case staleness per endpoint (max-age + stale-while-revalidate).

Session data marked cacheable

Common

GET /account (Cookie: sid=...)
Cache-Control: max-age=60

Prefer

GET /account (Cookie: sid=...)
Cache-Control: private, max-age=60

Why

The first response may be stored by a CDN and served to the next visitor. Authorization has protection in RFC 9111 §3.5; cookies do not.

The mistake shows up when a CDN is placed in front of an API that used to be browser-only:

Misunderstanding Consequence Do instead
No header means not cached Heuristic freshness serves old data Always send Cache-Control
s-maxage is “just for the CDN” It also unlocks shared caching of Authorization requests Use it only on non-personal data
A CDN purge clears every copy Browsers keep theirs until max-age ends Short browser max-age
  1. Why can the CDN answer the browser’s If-None-Match with 304 without contacting the origin?
  2. A response has public and Last-Modified but no max-age. Can a cache reuse it without validation?
  3. Why does If-Match use strong comparison while If-None-Match uses weak?
  • RFC 9111 — HTTP Caching: storage rules (§3), authenticated responses (§3.5), heuristics (§4.2.2), directives (§5.2).
  • RFC 9110 — HTTP Semantics: ETag comparison (§8.8.3), Vary (§12.5.5), preconditions and their order (§13), 304 (§15.4.5).
  • RFC 5861: exact semantics of stale-while-revalidate and stale-if-error.
  • RFC 8246: immutable and the versioned-URL pattern it assumes.