HTTP caching: who may store a response, and for how long? Intermediate
Estimated reading time: 10 min read
The problem
Section titled “The problem”An online shop serves three kinds of responses through a CDN:
app.3f9a1c.jsand 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?
Which header? (summary first)
Section titled “Which header? (summary first)”| 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 |
Core concepts
Section titled “Core concepts”Freshness vs validation
Section titled “Freshness vs validation”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.
Cache-Control directives
Section titled “Cache-Control directives”| 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.
Validators: ETag and Last-Modified
Section titled “Validators: ETag and Last-Modified”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).
Vary: the cache key
Section titled “Vary: the cache key”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.
How it works
Section titled “How it works”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.
GET → 200 + ETagt = 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.
Heuristic freshness
Section titled “Heuristic 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.
Authenticated responses
Section titled “Authenticated responses”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.
ETag for writes, not only reads
Section titled “ETag for writes, not only reads”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).
Both admins read product 42 and receive ETag "v7".
- Both admins hold
"v7"; the API is at"v7". - A’s tag matches; the API writes and moves to
"v8". - B’s
"v7"fails strong comparison; nothing is written, B gets412as a problem+json body. - B re-reads
"v8"and decides again.
If-Match checks a version; it does not deduplicate retried POSTs. That is the Idempotency-Key job.
Operational details
Section titled “Operational details”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-ageends. Keep browsermax-ageshort ands-maxagelonger; 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-storeis 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-revalidateneeds 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.
Real-world example
Section titled “Real-world example”Run the demo (Node 24, node:http only):
npx --prefix examples ts-node examples/src/http-caching/server.tsbash examples/src/http-caching/curl.sh # in another terminalTrimmed output from one run (hashes differ per data):
== 1. First GET: 200 with ETagHTTP/1.1 200 OKCache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30ETag: "0WXDeSn-DbFvuJpn"Vary: Accept-Encoding== 2. Conditional GET with If-None-Match: "0WXDeSn-DbFvuJpn"HTTP/1.1 304 Not ModifiedETag: "0WXDeSn-DbFvuJpn"== 3. Same URL, Accept-Encoding: gzipHTTP/1.1 200 OKETag: "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" -> 412Step 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.
Trade-offs
Section titled “Trade-offs”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
publicon 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.
Production checklist
Section titled “Production checklist”- Every response sends an explicit
Cache-Control; no endpoint relies on heuristic freshness. - Personal responses carry
private(orno-store), including cookie-session endpoints and error pages that echo user data. - Static assets have content hashes in their names and
immutable; HTML entry pages useno-cache. - Responses whose body depends on a request header list it in
Vary, and each representation has its own ETag. 304responses repeatETag,Cache-ControlandVary.- Writes on shared resources require
If-Matchand return412with a problem body. - A runbook lists purge commands and the worst-case staleness per endpoint (
max-age+stale-while-revalidate).
Common mistakes
Section titled “Common mistakes”Session data marked cacheable
Common
GET /account (Cookie: sid=...)Cache-Control: max-age=60Prefer
GET /account (Cookie: sid=...)Cache-Control: private, max-age=60Why
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 |
Self-check
Section titled “Self-check”- Why can the CDN answer the browser’s
If-None-Matchwith304without contacting the origin? - A response has
publicandLast-Modifiedbut nomax-age. Can a cache reuse it without validation? - Why does
If-Matchuse strong comparison whileIf-None-Matchuses weak?
Summary
Section titled “Summary”References
Section titled “References”- 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-revalidateandstale-if-error. - RFC 8246:
immutableand the versioned-URL pattern it assumes.