Module 06: HTTP Caching
Why this matters
Caching is the highest-leverage performance tool HTTP gives you, and it's
almost free — it's a handful of response headers. Done right, a browser or
CDN answers a request without ever touching your server, or with a tiny
"is this still current?" round trip that skips re-sending a megabyte of
data. Done wrong, users see stale content for hours (or you cache
something private and leak one user's data to another). The gap between
those two outcomes is entirely in how you set Cache-Control, ETag, and
Last-Modified — a couple of lines of code.
You already met the payoff code in module 05: 304 Not Modified, the
"reuse your copy" response. And you met the underlying idea way back in
module 00: DNS TTL — "how long may I reuse this answer before asking
again?" HTTP caching is that same time-to-live concept, plus a second,
smarter mechanism (validation) for "my copy might be stale, but let me
cheaply check instead of blindly refetching." This module ties both
together.
The two questions every cache header answers are: "May I reuse this, and for how long?" (freshness) and, once it's stale, "Has it actually changed, or can I keep using my copy?" (validation). Get those two straight and caching stops being mysterious.
Concepts
Where caches live
A "cache" isn't one thing. The same response can be cached at several layers between your server and the user (module 00's journey in reverse):
- The browser cache (private) — stores responses for one user on their device.
- A CDN / edge cache (shared) — stores responses near users, serving many of them from a copy without hitting your origin.
- A reverse proxy cache (shared) — nginx/Varnish in front of your app.
Your response headers instruct all of them. The critical safety rule:
private vs. public — a private response may only be stored by
the browser (one user); a public one may be stored by shared caches
(CDN/proxy). Cache a user-specific response (their profile, their cart) as
public and a shared cache can hand your data to the next user. This
is a real, severe bug class.
Freshness: Cache-Control and max-age
The primary caching header is Cache-Control, a general header
(module 02) carrying directives. The most important is max-age=<seconds>:
"this response is fresh for N seconds; during that window, reuse it
without asking me at all."
Cache-Control: max-age=3600
For 3600 seconds (1 hour) after fetching, the cache serves its stored copy directly — zero requests to your server. This is exactly DNS TTL (module 00) applied to HTTP responses. Common companion directives:
public— shared caches may store it.private— only the browser may store it (per-user data).no-cache— may store it, but must revalidate with the server before each reuse (a conditional request; see below). Misleadingly named: it does not mean "don't cache."no-store— do not store this at all, anywhere. For truly sensitive responses (bank balances, one-time tokens).must-revalidate— once stale, don't serve the stale copy; you must revalidate first.s-maxage=<seconds>— likemax-agebut only for shared caches (overridesmax-agefor CDNs/proxies).immutable— this will never change during itsmax-age; don't even revalidate on reload (great for versioned static assets likeapp.abc123.js).
The mental model: max-age = "reuse blindly for this long"; no-cache =
"reuse only after checking"; no-store = "never keep it."
Validation: ETag and Last-Modified
Freshness answers "may I reuse without asking?" But when a response goes
stale (its max-age expired), the cache doesn't have to blindly
re-download everything. It can ask "has this actually changed?" cheaply,
using a validator the server previously gave it:
ETag: "a1b2c3"— an opaque "version fingerprint" of the response body (often a hash). If the body changes, the ETag changes.Last-Modified: Sat, 26 Jul 2026 09:00:00 GMT— when the resource last changed (a coarser, second-resolution validator).
On the next request for a stale resource, the cache sends a conditional request echoing the validator:
If-None-Match: "a1b2c3"— "only send the body if the ETag is not stilla1b2c3."If-Modified-Since: Sat, 26 Jul 2026 09:00:00 GMT— "only send the body if it changed after this time."
The server compares. Two outcomes:
- Unchanged → the server returns
304 Not Modifiedwith no body. The cache reuses its stored copy. You just saved re-sending the whole payload — only headers crossed the wire. - Changed → the server returns
200 OKwith the new body and a newETag/Last-Modified.
This is why 304 (module 05) is a 3xx: it directs the client to use its
cache. Validation is the "cheap check" half of caching; freshness is the
"don't even check" half.
1st request (nothing cached yet) later request (copy is stale)
client ─ GET /doc ───────────► server client ─ GET /doc ───────────► server
◄─ 200 OK ─ │ If-None-Match: "a1b2c3"
ETag: "a1b2c3" │
Cache-Control: max-age=30 ▼ server compares ETag
<full 1 MB body> ┌── unchanged ──► 304 Not Modified
client stores copy + ETag │ (no body — reuse cache)
└── changed ────► 200 OK + new ETag
<full new body>
Strong vs. weak ETags
An ETag can be strong ("a1b2c3" — byte-for-byte identical) or
weak (W/"a1b2c3" — semantically equivalent but maybe not
byte-identical, e.g. same content with a different compression). Strong
ETags are required for range requests (resuming partial downloads); weak
ETags are fine for ordinary "did this change?" validation. Most APIs use
strong ETags derived from a hash of the serialized body.
Vary: caching under content negotiation
If a response differs based on a request header — say you return JSON or
XML depending on Accept (module 08), or gzip vs. plain based on
Accept-Encoding — a shared cache must not serve the JSON copy to a
client that asked for XML. The Vary response header tells caches
which request headers to include in the cache key:
Vary: Accept, Accept-Encoding
"Cache separate copies per distinct Accept and Accept-Encoding." Omit
Vary when you negotiate and a cache will serve the wrong representation
to someone. This header is the bridge between caching (this module) and
content negotiation (module 08).
shared cache keyed by URL + the Vary'd headers:
key: /doc | Accept: application/json | Accept-Encoding: gzip ─► [gzipped JSON]
key: /doc | Accept: application/xml | Accept-Encoding: (none)─► [plain XML]
▲ same URL, different stored copies — no cross-serving
Putting freshness + validation together
A well-cached API response typically carries both: a max-age (so
during the fresh window there are zero requests) and an ETag (so once
stale, revalidation is a cheap 304 instead of a full refetch). Example:
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
ETag: "v3-a1b2c3"
Vary: Accept-Encoding
Content-Type: application/json
For 60 seconds: reuse with no request. After 60 seconds: send
If-None-Match: "v3-a1b2c3"; get a 304 if nothing changed, a fresh
200 if it did.
Command reference
| Command | What it does |
|---|---|
curl -s -D - -o /dev/null URL |
Inspect caching response headers |
curl -s -H 'If-None-Match: "TAG"' -i URL |
Send a conditional request by ETag |
curl -s -H 'If-Modified-Since: <http-date>' -i URL |
Conditional request by date |
curl -s -w '%{http_code}\n' -H 'If-None-Match: "TAG"' -o /dev/null URL |
See 304 vs 200 |
Notes:
- To test validation manually: first
GETand note theETag, then re-GET withIf-None-Match: "<that etag>"and confirm you get304with no body. Change the resource, repeat, and confirm you get200with a new ETag.
FastAPI app with real caching (used in exercises):
import hashlib, json
from fastapi import FastAPI, Request, Response, status
app = FastAPI()
DOC = {"version": 1, "title": "Hello", "body": "world"}
def etag_for(obj) -> str:
raw = json.dumps(obj, sort_keys=True).encode()
return '"' + hashlib.sha256(raw).hexdigest()[:16] + '"'
@app.get("/doc")
def get_doc(request: Request, response: Response):
etag = etag_for(DOC)
# Validation: if the client's copy matches, short-circuit to 304
if request.headers.get("if-none-match") == etag:
return Response(status_code=status.HTTP_304_NOT_MODIFIED,
headers={"ETag": etag, "Cache-Control": "public, max-age=30"})
response.headers["ETag"] = etag
response.headers["Cache-Control"] = "public, max-age=30"
response.headers["Vary"] = "Accept-Encoding"
return DOC
@app.post("/doc")
def edit_doc(changes: dict):
DOC.update(changes)
DOC["version"] += 1
return DOC
@app.get("/static/app.js")
def static_asset(response: Response):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return Response(content="console.log('v1')", media_type="application/javascript",
headers={"Cache-Control": "public, max-age=31536000, immutable"})
@app.get("/me")
def me(response: Response):
# Per-user data: must NOT be shared-cached
response.headers["Cache-Control"] = "private, no-store"
return {"user": "ada", "balance": 4200}
Hands-on exercises
Run with uvicorn app:app --reload.
1. See freshness headers
curl -s -D - -o /dev/null http://127.0.0.1:8000/doc
Expected: cache-control: public, max-age=30, an etag: "...", and
vary: Accept-Encoding. Note the ETag value — you'll use it next.
2. Revalidate and get a 304
Copy the ETag from exercise 1, then:
curl -s -i -H 'If-None-Match: "PASTE_ETAG_HERE"' http://127.0.0.1:8000/doc
Expected: HTTP/1.1 304 Not Modified with no body. The server told
you "your copy is current — reuse it." Only headers crossed the wire; the
document body did not. That's the bandwidth win.
3. Change the resource, watch the ETag change
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"title":"Changed"}' http://127.0.0.1:8000/doc
curl -s -i -H 'If-None-Match: "PASTE_OLD_ETAG_HERE"' http://127.0.0.1:8000/doc
Expected: now you get 200 OK with the new body and a different
ETag — the old validator no longer matches because the content changed.
Validation correctly detected the change.
4. Prove no-store on per-user data
curl -s -D - -o /dev/null http://127.0.0.1:8000/me
Expected: cache-control: private, no-store. No shared cache will store
this, and even the browser won't persist it. Reason about why serving
/me as public, max-age=60 would be a data-leak bug (a CDN could hand
Ada's balance to the next user).
5. Immutable versioned asset
curl -s -D - -o /dev/null http://127.0.0.1:8000/static/app.js
Expected: cache-control: public, max-age=31536000, immutable. A browser
caches this for a year and won't even revalidate on reload. This is the
standard strategy for fingerprinted assets (app.abc123.js): the URL
changes when the content changes, so the cached copy is never wrong.
6. max-age vs no-cache, in your head then in curl
Change /doc's Cache-Control to no-cache (keep the ETag logic),
restart, and re-run exercises 1-2.
Expected: with no-cache, a compliant cache stores the copy but
revalidates every time before reuse — so you'll always see the
conditional request happen (and get 304s when unchanged). Contrast:
max-age=30 skips the server entirely for 30s. Write down the difference
in one sentence.
7. Vary and negotiation interaction
curl -s -D - -o /dev/null -H 'Accept-Encoding: gzip' http://127.0.0.1:8000/doc
Expected: the vary: Accept-Encoding header is present. Explain what a
shared cache does with it: it keys its stored copies by Accept-Encoding,
so a client that sent gzip and one that sent nothing get the correct
respective representations — the cache won't hand a gzipped body to a
client that can't decode it (module 08).
8. Diagnose and fix: the private-data leak
Here's a broken endpoint. Add it:
@app.get("/profile")
def profile(response: Response):
# BUG: per-user data marked public and cacheable by shared caches
response.headers["Cache-Control"] = "public, max-age=3600"
return {"user": "ada", "email": "ada@x.com", "secret_prefs": [1, 2, 3]}
curl -s -D - -o /dev/null http://127.0.0.1:8000/profile
Expected: cache-control: public, max-age=3600 on clearly per-user data.
Diagnose: a shared cache (CDN/proxy) may store Ada's profile and serve
it to the next user who requests /profile for an hour — a
cross-user data leak. Fix: mark it private, no-store (or at least
private, max-age=0, must-revalidate if some per-browser caching is
acceptable). Re-run and confirm it's no longer public. Lesson:
public vs. private is a security decision, not just performance.
9. Measure the win
# Full response (200) size vs. a revalidation (304) size
curl -s -o /dev/null -w 'full: %{size_download} bytes\n' http://127.0.0.1:8000/doc
ETAG=$(curl -s -D - -o /dev/null http://127.0.0.1:8000/doc | grep -i etag | tr -d '\r' | awk '{print $2}')
curl -s -o /dev/null -w '304: %{size_download} bytes\n' -H "If-None-Match: $ETAG" http://127.0.0.1:8000/doc
Expected: the 304 downloads ~0 body bytes versus the full response's
payload. On a real API returning kilobytes or megabytes, that's the
concrete savings validation buys — repeated for every client, every
revalidation.
Independent challenge
No code given.
Task: Implement caching for a /report endpoint that returns an
expensive-to-generate JSON report which changes at most once an hour.
Requirements: (a) during the freshness window, browsers and your CDN
should serve it without contacting your server at all; (b) once stale, a
client should be able to revalidate and receive a 304 (module 05) if the
report hasn't regenerated, saving the full payload; (c) the report is the
same for all users, so shared caching is desirable — but a different
endpoint /report/mine returns a per-user version that must never be
stored by a shared cache. Prove each behavior with curl: show the fresh
window serves without revalidation, show a 304 on revalidation, and show
/report/mine carries directives that forbid shared caching. Tie your
choices back to the DNS-TTL analogy from module 00 and the 304 mechanic
from module 05.
Hint
For /report: Cache-Control: public, max-age=3600 plus an ETag
derived from a hash of the report content (or its generation timestamp),
and the same If-None-Match → 304 short-circuit as the /doc example.
For /report/mine: Cache-Control: private, no-store (or at least
private). The public/private split is the whole safety story.
Common mistakes & troubleshooting
- Caching per-user data as
public. A shared cache can then leak one user's response to another. Per-user data must beprivate(and oftenno-store). - Thinking
no-cachemeans "don't cache." It means "cache, but revalidate before every reuse." "Don't store at all" isno-store. - Negotiating content without
Vary. A shared cache will serve the wrong representation (JSON to an XML client, gzip to a client that can't decode it). AddVarywith the relevant request headers. - No validator on cacheable responses. Without an
ETag/Last-Modified, a stale cache must refetch the entire body instead of getting a cheap304. Always pairmax-agewith a validator for large responses. immutableon things that actually change. Only use it for content-addressed/versioned URLs whose content is fixed for that URL.- Very long
max-ageon non-versioned URLs. Users get stuck with stale content and no easy way to bust it. Use shortmax-age+ revalidation, or versioned URLs + longmax-age. - Forgetting that
304responses should carry the same cache headers. A304should refresh freshness (Cache-Control) so the cached copy's clock resets.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What two distinct questions do caching headers answer? Name the mechanism (and key header) for each.
- What does
max-age=60cause a cache to do for the first 60 seconds, and what changes after that? - Distinguish
no-cache,no-store, andprivate— one clear sentence each. - Walk through a conditional request: what does the client send, what are the server's two possible responses, and which one saves bandwidth and how?
- Why is serving a per-user response with
Cache-Control: public, max-age=3600a security bug, not just a performance choice? - You return JSON or XML depending on the
Acceptheader. What header must you add so shared caches don't serve the wrong one, and what does it do? - What's the standard caching strategy for a fingerprinted asset like
app.9f8a.js, and why is it safe to cache it for a year?
Answers
- (a) "May I reuse this, and for how long?" — freshness, via
Cache-Control: max-age. (b) "Has it actually changed?" — validation, viaETag/Last-Modifiedand conditional requests. - For 60 seconds the cache serves its stored copy directly with no request to the server (fresh). After 60 seconds it's stale and the cache must revalidate (or refetch) before reuse.
no-cache= may store but must revalidate before each reuse.no-store= never store it anywhere.private= only the browser (one user) may store it, not shared caches.- The client sends
If-None-Match: "<etag>"(orIf-Modified-Since). The server returns304 Not Modified(no body) if unchanged, or200 OKwith the new body + new validator if changed. The304saves bandwidth by not re-sending the body — only headers cross the wire. - Because
publiclets a shared cache (CDN/proxy) store it and serve the same stored response to other users — leaking one user's private data to another for up to an hour. Vary: Accept— it tells shared caches to include theAcceptrequest header in the cache key, so each representation is cached and served separately.Cache-Control: public, max-age=31536000, immutable. It's safe because the filename (fingerprint/hash) changes whenever the content changes — a given URL's bytes never change, so a cached copy is never stale.
Further reading & sources
- MDN: HTTP caching - the end-to-end guide to freshness, validation, and the
private/publicsplit. - RFC 9111: HTTP Caching - the authoritative spec for
Cache-Control,Vary, and conditional requests. - MDN: Cache-Control - every directive (
max-age,no-cache,no-store,immutable) defined precisely. - MDN: ETag and If-None-Match - the validator and the conditional-request header that yields
304. - web.dev: HTTP caching - a practical, strategy-focused walkthrough including the fingerprinted-asset pattern.
- MDN: Vary - how the cache key changes under content negotiation, bridging to module 08.
Next
07-http-1-1-vs-2-vs-3 — you've mastered HTTP's semantics on HTTP/1.1; now see how HTTP/2 and HTTP/3 keep those exact semantics but radically change performance on the wire.