07 · Caching Headers (ETag, Cache-Control)¶
Level 1 introduced Cache-Control as one of REST's core constraints
(cacheability). This module covers the actual headers that implement it —
Cache-Control, ETag, and conditional requests — and how they combine to
avoid re-sending data the client already has.
Cache-Control directives¶
| Directive | Meaning |
|---|---|
public |
Any cache (browser, CDN, proxy) may store this response |
private |
Only the end client may cache it, not a shared proxy/CDN (typical for per-user data) |
no-cache |
May be cached, but must be revalidated with the server before reuse (misleadingly named — it doesn't mean "don't cache") |
no-store |
Must not be cached anywhere, ever — for sensitive data |
max-age=N |
Fresh for N seconds; safe to reuse without contacting the server at all during that window |
must-revalidate |
Once stale, must revalidate before use, even if the client would otherwise serve a stale copy |
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
Content-Type: application/json
{"id": 42, "title": "Dune"}
A client can reuse this response for 60 seconds without another network call. After that, it should revalidate.
ETags and conditional GET¶
An ETag is an opaque fingerprint of a resource's current state (often a
hash of its content). It lets a client ask "has this changed since I last
saw ETag X?" instead of re-downloading the whole thing.
HTTP/1.1 200 OK
ETag: "a1b2c3d4"
Cache-Control: private, max-age=60
Content-Type: application/json
{"id": 42, "title": "Dune", "price": 15.99}
Once the cached copy goes stale, the client revalidates with
If-None-Match:
If unchanged:
304 Not Modified has no body — the client is told to keep using its
cached copy, saving the full payload transfer. If the resource has
changed, the server responds normally with 200 and the new body and
ETag.
Last-Modified as a lighter alternative¶
Last-Modified/If-Modified-Since is coarser (second-level precision,
relies on accurate clocks/timestamps) than an ETag, but cheaper to
compute when a resource already has a reliable updated_at column — no
hashing required.
ETags for optimistic concurrency control¶
ETag doubles as a concurrency mechanism via If-Match on writes: "only
apply this update if the resource still matches the version I last read."
curl -X PUT https://api.example.com/books/42 \
-H 'If-Match: "a1b2c3d4"' \
-d '{"title": "Dune", "price": 12.99}'
If another client updated the book in the meantime (ETag changed), the
server rejects with 412 Precondition Failed instead of silently
overwriting the newer change — a lost-update bug in the making otherwise.
HTTP/1.1 412 Precondition Failed
Content-Type: application/json
{
"error": {
"code": "precondition_failed",
"message": "Resource has changed since it was last fetched. Refetch and retry."
}
}
This is the same pattern as optimistic locking with a version column in
a database, expressed over HTTP.
Combining Cache-Control and ETag¶
Read together: "cache me for 60 seconds without asking; after that, don't
use a stale copy — ask again, but if my content is unchanged, just tell me
304 instead of resending everything." This combination gets both speed
(no network round trip inside the freshness window) and correctness (no
stale-forever reuse) without over-fetching unchanged data.
Worked example: a cache-aware client¶
import requests
def get_book(id, cache):
entry = cache.get(id)
headers = {}
if entry and "etag" in entry:
headers["If-None-Match"] = entry["etag"]
resp = requests.get(f"https://api.example.com/books/{id}", headers=headers)
if resp.status_code == 304:
return entry["body"] # unchanged, use cached copy
body = resp.json()
cache[id] = {"body": body, "etag": resp.headers.get("ETag")}
return body
This client transfers the full book payload only on the first fetch and any
fetch after a real change — every unchanged revalidation costs a small
request/304 round trip instead of the full body.
How It Actually Works¶
Cache-Control and ETag implement two different validation strategies,
and understanding the actual comparison logic explains when each saves a
network round trip versus a full response body.
Cache-Control: max-age=3600 is a time-based freshness check done
entirely by the client/cache — no server involvement. The cache stores the
response plus the time it arrived; on a later request, it computes
age = now - stored_time, and if age < max_age, it returns the stored
copy without contacting the server at all. This is the fastest path —
zero network round trips — but risks serving stale data if the resource
changed before max_age elapsed.
ETag is a content-based validator — the server computes a hash (or
version stamp) of the resource body, e.g. ETag: "a1b2c3". On a
subsequent request, the client sends If-None-Match: "a1b2c3", and the
server recomputes the current ETag and does a literal string comparison:
if current_etag == request's If-None-Match:
return 304 Not Modified (empty body, headers only)
else:
return 200 OK (full body, new ETag)
A 304 still requires a full round trip to the server (unlike max-age),
but saves re-transferring the (possibly large) response body — the server
did real work (recomputing the hash) to save the network, not the
server's own CPU. Combining both (Cache-Control: max-age=60 plus an
ETag) is why real APIs use both: max-age avoids the round trip entirely
while fresh, and ETag validation kicks in cheaply once that window
expires, before falling back to a full re-fetch.
Exercise¶
- Explain the practical difference between
no-cacheandno-store, and give one type of API response where each would be the right choice. - Two clients
GETthe same book, both cache itsETag. Client APUTs an update. What should happen when Client B then tries toPUTits own (now stale) update usingIf-Matchwith its oldETag? - Why does
304 Not Modifiedhave no response body, and why does that matter for a large resource (e.g. a 2MB report)? - When would
Last-Modified/If-Modified-Sincebe preferable to a computedETag, and when would it be insufficient?