Chapter 40 — Caching: the biggest performance lever there is
In one sentence
The fastest request is the one you never make, and HTTP has a rich, precise system for saying how long a response may be reused.
The problem
Every request costs round trips (Chapter 4's latency budget), bandwidth, and server work. Most content doesn't change between requests. Caching removes the request entirely.
The three caches
BROWSER CACHE CDN / PROXY CACHE ORIGIN SERVER
(private -- one user) (shared -- many users) (the source of truth)
| | |
fastest, free fast, near the user slow, expensive
The private vs shared distinction runs through everything below: a response containing your bank balance may be cached by your browser but must never be cached by a shared CDN.
Cache-Control: the directives that matter
Cache-Control: public, max-age=31536000, immutable
\____/ \______________/ \_______/
who may how long fresh never even
store it (seconds) revalidate
| Directive | Means |
|---|---|
max-age=N |
Fresh for N seconds |
s-maxage=N |
Same, but for shared caches only (overrides max-age there) |
public |
Shared caches may store it |
private |
Browser only — CDNs must not store it |
no-cache |
You may store it, but check with me before every reuse |
no-store |
Never store it anywhere. This is the real "don't cache". |
must-revalidate |
Once stale, don't serve it — check or fail |
immutable |
This will never change; don't revalidate even on a reload |
stale-while-revalidate=N |
Serve the stale copy for up to N s while fetching a fresh one in the background |
The single most common mistake in web development: using
no-cachewhen you meanno-store.no-cachepermits storage — it just requires revalidation. If the response contains something secret, you wantno-store.
Revalidation: the 304
When a cached copy goes stale, the client doesn't re-download blindly — it asks "has this changed?"
Original response: Later request:
ETag: "v3-abc123" If-None-Match: "v3-abc123"
Last-Modified: Tue, 01 ... If-Modified-Since: Tue, 01 ...
Server, if unchanged:
HTTP/1.1 304 Not Modified
(headers only -- NO BODY)
-> the client reuses what it already has.
A 2 MB image cost ~200 bytes to confirm.
ETag is a version token — usually a hash of the content. Last-Modified
is a timestamp (1-second resolution, so ETag is more precise).
304 is not an error. Seeing lots of them in your logs means caching is working.
Vary: the header that keeps caches honest
A cache stores one response per URL. But what if the response depends on a request header?
Same URL, different responses:
Accept-Encoding: gzip -> compressed bytes
Accept-Encoding: (none) -> plain bytes
Without "Vary: Accept-Encoding", a cache might hand compressed
bytes to a client that can't decompress them.
Vary: Accept-Encoding almost always needed if you compress
Vary: Accept-Language one cached copy per language
Vary: Cookie -> effectively uncacheable in a shared cache
Vary: User-Agent -> cache explosion; avoid
The pattern that actually works
This is the standard modern setup, and it's worth copying exactly:
ASSETS (JS, CSS, images) -- give them a content hash in the filename:
/static/app.9f3c2a1.js
Cache-Control: public, max-age=31536000, immutable
-> cached for a year, never revalidated.
Content changed? The FILENAME changes, so it's a different URL.
HTML -- the file that references those assets:
Cache-Control: no-cache
-> always revalidated (cheap: a 304 if unchanged),
so a deploy is visible immediately.
API responses (personal data):
Cache-Control: private, no-store
This gives you near-perfect caching and instant deploys, with no purging.
Practice (30 min)
A. Look at real caching headers:
curl -sI https://example.com | grep -iE 'cache-control|etag|last-modified|age|vary|expires'
Try a few sites and compare. Look at a big site's static assets versus its HTML.
B. Do a conditional request yourself and get a 304:
URL=https://example.com
ET=$(curl -sI $URL | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
echo "ETag is: $ET"
curl -sI -H "If-None-Match: $ET" $URL | head -1 # expect: 304 Not Modified
C. See a CDN hit vs miss:
curl -sI https://www.cloudflare.com | grep -iE 'cf-cache-status|age'
curl -sI https://www.cloudflare.com | grep -iE 'cf-cache-status|age' # run twice
Age: tells you how many seconds the cache has held it.
D. In your browser: DevTools → Network. Reload a page and look at the Size column — entries saying "(disk cache)" or "(memory cache)" cost zero network. Then tick Disable cache and reload; watch the load time and transfer size change. That difference is what caching is worth.
Common confusions
- "
no-cachemeans don't cache." It doesn't.no-storedoes. - "Why is my update not showing?" Something along the path cached it with a
long
max-ageand no revalidation. This is why hashed filenames exist. - "Should I use
ExpiresorCache-Control?"Cache-Control— it's newer and wins when both are present.Expiresis an absolute date and suffers from clock skew. - "Does HTTPS prevent caching?" No. Browsers and your own CDN cache HTTPS responses normally. Only unrelated intermediaries can't.
Check yourself
- What's the difference between
no-cacheandno-store? - What is an
ETagand what does a304save you? - Why does
Vary: Accept-Encodingmatter? - Why do modern sites put a hash in asset filenames?
Further reading
- Article (best on the topic): "Caching best practices & max-age gotchas" — Jake Archibald. Explains the hashed-filename pattern definitively.
- Docs: MDN, "HTTP caching" — thorough and accurate.
- Book (free): High Performance Browser Networking, the HTTP chapters.
- Tool:
redbot.org— paste a URL, get an expert analysis of its caching headers and what's wrong with them. Genuinely useful.