HTTP Caching, ETags & CDNs
Cache-Control, ETag and Last-Modified headers, conditional requests, and how CDNs cache content at the edge.
HTTP has caching built in
The fastest request is the one that never reaches your server. HTTP defines headers that tell browsers, proxies and CDNs whether a response can be cached, for how long, and how to check if it's still valid. Getting these headers right is free performance.
Cache-Control: the main switch
| Directive | Meaning |
|---|---|
max-age=3600 |
Fresh for 3600 seconds in any cache |
s-maxage=86400 |
Overrides max-age for shared caches (CDNs, proxies) |
public |
Any cache may store it, even for authenticated requests |
private |
Only the user's browser may store it (personal data) |
no-cache |
Store it, but revalidate with the server before every use |
no-store |
Never store it anywhere (sensitive data) |
immutable |
Will never change during its lifetime — don't revalidate |
stale-while-revalidate=60 |
Serve stale for up to 60 s while fetching a fresh copy in the background |
Typical policies:
# Versioned static assets: app.3f9a1c.js
Cache-Control: public, max-age=31536000, immutable
# Public HTML page that changes occasionally
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60
# A logged-in user's dashboard
Cache-Control: private, no-cache
# Banking or payment responses
Cache-Control: no-store
no-cache does not mean "don't cache" — it means "check with me first". Use no-store when data must never be stored.
Validation: ETag and Last-Modified
When a cached response is stale, the client can ask "has it changed?" instead of downloading it again.
- The server sends an
ETag(a fingerprint of the content) orLast-Modified. - The client later sends
If-None-Match: <etag>orIf-Modified-Since: <date>. - If nothing changed, the server replies
304 Not Modifiedwith no body.
import hashlib
import json
def make_etag(body: bytes) -> str:
return '"' + hashlib.sha256(body).hexdigest()[:16] + '"'
def handle_get(course: dict, if_none_match: str | None):
body = json.dumps(course, sort_keys=True).encode()
etag = make_etag(body)
if if_none_match == etag:
return 304, {"ETag": etag}, b"" # client's copy is still valid
return 200, {"ETag": etag, "Cache-Control": "no-cache"}, body
course = {"id": 1, "title": "Operating Systems"}
status, headers, body = handle_get(course, None)
print(status, headers["ETag"], len(body), "bytes")
status, headers, body = handle_get(course, headers["ETag"]) # browser revalidates
print(status, len(body), "bytes")
course["title"] = "Operating Systems (2026 edition)"
status, headers, body = handle_get(course, '"stale-etag"')
print(status, len(body), "bytes")
Output:
200 "401e4c7a6f35767e" 39 bytes
304 0 bytes
200 54 bytes
The 304 saves bandwidth and serialisation time; for large JSON or HTML responses the savings are significant. Note that the ETag must be built from a stable representation (sort_keys=True) or it will change on every request.
The Vary header
If a response differs by request header — language, encoding, device — tell caches with Vary:
Vary: Accept-Encoding, Accept-Language
Without it, a CDN could serve an Urdu page to an English user. Avoid Vary: Cookie on public pages; it destroys cache reuse.
CDNs: caching at the edge
A Content Delivery Network keeps copies of your content in data centres close to users. A student in Lahore downloads images from a nearby edge server instead of a server on another continent.
How a CDN request works:
- The user's DNS lookup routes them to the nearest edge location.
- Edge hit → served immediately.
- Edge miss → the CDN fetches from your origin server, caches it per your headers, and serves it.
CDN best practices:
- Fingerprint static files (
app.3f9a1c.js) and cache them for a year — deploying a new version changes the file name, so no purge is needed. - Use
s-maxageto cache HTML at the edge longer than in browsers. - Purge by URL or tag when content changes urgently.
- Never cache authenticated responses publicly — mark them
privateor bypass the CDN for them. - Serve images in modern formats (WebP/AVIF) and correct sizes.
Caching API responses
APIs benefit too:
- Public, read-only endpoints (course catalogue, exchange rates) can use
Cache-Control: public, max-age=…and a CDN. - Per-user endpoints should be
privateand rely on ETags for revalidation. POST,PUTandDELETEresponses aren't cached by default.
Common mistakes
max-ageon HTML without versioning, so users keep seeing an old page after a deploy.- Public caching of personalised responses — one user sees another's data.
- Unstable ETags that change every request, so
304never happens. - Purging the whole CDN on every deploy instead of fingerprinting assets.
Key takeaways
Cache-Controldecides who may cache and for how long;no-storefor sensitive data.- ETags and
304 Not Modifiedavoid re-sending unchanged content. - CDNs cache at the edge — fingerprint static assets and cache them for a year.
- Mark personalised responses
privateand useVarycorrectly.