TL;DR
- Static assets: fingerprint filenames and serve with
Cache-Control: public, max-age=31536000, immutable. Cache hard at CDN + browser; deploy new URLs on change. - HTML & APIs: make them revalidatable: send ETag (or Last-Modified) and short freshness (
max-age=0or small), plusstale-while-revalidate/stale-if-error. - Shared caches (CDN): use
s-maxage(edge TTL) that can be longer thanmax-age. Optionally use Surrogate-Control headers on CDNs that support them. - Personalized/auth responses aren’t shared by default. Use
private(browser-only) or explicitly allow withpublic/s-maxage+Vary: Authorizationwhen safe. - Keep
Varytight (e.g.,Accept-Encoding,Accept-Language). Over-varying destroys hit rate. - Prefer strong ETags (content hash). Use weak ETags (
W/) when bytes differ but representation is equivalent (e.g., gzip differences).
1) Freshness model (how caches decide to serve vs ask)
A cache response is fresh if within its freshness lifetime; otherwise it’s stale and needs revalidation.
Explicit freshness (best)
Cache-Control: max-age=60, s-maxage=300, stale-while-revalidate=60, stale-if-error=300
max-age: browser (and shared caches) freshness in seconds.s-maxage: overridesmax-agefor shared caches (CDN/proxies).stale-while-revalidate: serve stale while revalidating in background.stale-if-error: serve stale on 5xx/timeouts.
Heuristic freshness (avoid): if no max-age, caches may derive it from Last-Modified. Prefer explicit headers.
Must revalidate
Cache-Control: max-age=0, must-revalidate
- Always revalidate when stale; no heuristic freshness.
2) Validators: ETag & Last-Modified
- ETag (opaque token, ideally a content hash):
ETag: "b3c3b8b6" # strong (byte-for-byte)
ETag: W/"b3c3b8b6" # weak (semantically same)
- Last-Modified:
Last-Modified: Wed, 24 Sep 2025 10:21:00 GMT
Conditional requests
Client sends:
If-None-Match: "b3c3b8b6"
If-Modified-Since: Wed, 24 Sep 2025 10:21:00 GMT
Server replies 304 Not Modified if unchanged, 200 with body if changed. Prefer ETag; time-based validation is coarser.
3) Cache-Control directives that matter
public/private— allow shared caches vs browser-only.no-store— don’t write to cache at all (use for highly sensitive).no-cache— may store, but must revalidate before each use.max-age=N— freshness lifetime for all caches.s-maxage=N— freshness for shared caches only.must-revalidate— once stale, no serving without revalidation.immutable— representation won’t change while fresh (great for fingerprinted assets).
4) Vary: pick the minimal key set
Vary tells caches which request headers affect the response. Too many → tiny hit rates; too few → incorrect content.
Common, safe values:
Vary: Accept-Encoding
Vary: Accept-Language
For content negotiation (e.g., API returns JSON vs HTML):
Vary: Accept
Personalization/auth (only if deliberately caching at CDN):
Cache-Control: public, s-maxage=60
Vary: Authorization
Be careful:
Vary: *disables reuse in shared caches.
5) Patterns by content type
A) Fingerprinted static assets (JS/CSS/images)
- Build assets with hashes in filenames (e.g.,
app.ab12cd34.js). - Serve:
Cache-Control: public, max-age=31536000, immutable
ETag: "ab12cd34" # optional when filename is already content-hashed
- Deploy: change URL when content changes; no purges needed.
Nginx
location ~* \.(?:js|css|png|jpg|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
B) SSR/HTML documents
- Revalidatable, short freshness (CDN can be longer):
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30
ETag: "page-2025-09-24T10:20Z"
- Avoid immutable on HTML. If personalized, use
private(browser-only) or separate by cookie/auth at the edge.
C) API GET endpoints
- Shared cache when responses are tenant/anon-safe:
Cache-Control: public, max-age=30, s-maxage=120, stale-while-revalidate=60
ETag: "list-v2-9a7f"
Vary: Accept, Accept-Encoding
- Personalized responses: use
private, max-age=0(browser can revalidate with ETag). - For infrequently changing resources, send longer
max-ageand rely on ETag revalidation for freshness.
D) Truly sensitive data
Cache-Control: no-store
Pragma: no-cache # legacy proxies
No ETag, no caching.
6) CDN/Reverse proxy specifics (mental model)
- Edge TTL: set via
s-maxageor Surrogate-Control (Fastly/Akamai). - Cache key: define which request parts matter (host, path, query, headers). Ignore noisy query params when safe.
- Purge/Invalidate: prefer surrogate keys/tags to invalidate groups (e.g., all pages with product 123).
- Compression: enable Brotli/gzip at the edge; ensure
Vary: Accept-Encodingis present. - HTTPS: caches should normalize to the same origin; don’t mix schemes inadvertently.
Express example (API)
app.get("/api/products", (req, res) => {
res.set("Cache-Control", "public, max-age=30, s-maxage=180, stale-while-revalidate=60");
res.set("ETag", makeStrongEtag(resBody)); // content hash
res.set("Vary", "Accept, Accept-Encoding");
res.json(resBody);
});
7) Service Worker (cache + revalidate pattern)
// sw.js
self.addEventListener("fetch", event => {
const req = event.request;
event.respondWith((async () => {
const cache = await caches.open("v1");
const cached = await cache.match(req);
const fetchPromise = fetch(req).then(async res => {
if (res.ok) await cache.put(req, res.clone());
return res;
}).catch(() => cached || Response.error());
return cached || fetchPromise; // cache-first, then update
})());
});
Use HTTP headers in addition to SW logic; they help CDN + browser when SW is absent/disabled.
8) Debugging & observability
- In DevTools Network: look for (from disk cache) / (from memory cache) and 304 responses.
- Log
ETag,Age,Cache-Control,Varyat the edge. - Track hit rate, revalidation rate, and 5xx served via
stale-if-error.
Revalidation flow
- Browser has a stale cached response with
ETag: "abc" - Sends
If-None-Match: "abc" - Server replies 304, small payload, cache updates freshness.
9) Pitfalls & fast fixes
| Pitfall | Why it bites | Fix |
|---|---|---|
| No explicit freshness | Heuristic caching, inconsistent behavior | Set max-age/s-maxage explicitly |
| ETag varies per server | Always miss 304s | Use content hash; avoid inode/mtime-based tags |
| Over-broad Vary (e.g., User-Agent) | Zero hit rate | Keep Vary minimal (Accept-*, encoding, auth when needed) |
| Authenticated GETs cached by CDN accidentally | Data leakage | Use private or add explicit public,s-maxage + Vary: Authorization |
| Mixing no-store with immutable | Contradiction | Choose one; no-store wins |
| Relying on cache purge for assets | Complexity & risk | Fingerprint URLs and cache forever |
| Forgetting Date header | Freshness math breaks | Ensure server/proxy sets correct Date |
Quick checklist
- [ ] Fingerprint assets; serve with
max-age=31536000, immutable. - [ ] HTML/API: ETag + short
max-age(ands-maxagefor CDNs) +stale-while-revalidate. - [ ] Keep
Varyminimal and deliberate. - [ ] Use public/private correctly; avoid caching sensitive data.
- [ ] Define CDN cache key and purge by tags/keys.
- [ ] Monitor hit rate, Age, and 304 rates; fix misses.
One‑minute adoption plan
- Turn on filename fingerprinting for static assets and set immutable caching.
- Add ETag to HTML/API responses; set
max-age=0, s-maxage=60, stale-while-revalidate=30. - Audit and trim Vary headers; ensure
Accept-Encodingis present when compressing. - Configure CDN cache key and purge by surrogate key; stop purging assets.
- Add dashboards for edge hit rate and 304 ratio; iterate.