HTTP Caching
Cache-Control, ETag, stale-while-revalidate, CDNs.
HTTP Caching
A cache is the fastest server — the one that does not exist. Every resource your app serves is either cached or not, and the decision is made by a chain of headers that most engineers never inspect until something breaks.
Analogy
HTTP caching is like the milk supply chain at a coffee shop. There's a fridge right behind the counter (the browser cache), a walk-in cooler in the back (the CDN edge cache), and the dairy farm a hundred miles out (the origin server). The sticker on each carton says "best by Thursday at 3pm" (max-age) — nobody has to call the farm until that sticker expires. stale-while-revalidate is the barista who'll pour you the last cup from a just-expired carton while a new one is being brought up from the cooler, so you never wait. ETag is the serial number printed on the carton: when a carton expires, the counter can phone the farm and ask "is carton #4291 still the current batch?" — if yes, the farm says "still good, keep using it" without shipping a replacement. Versioned URLs (main.abc123.js) are simply giving each new batch a completely different product code, so nobody could possibly confuse it with last week's supply.
The cache hierarchy
browser cache → CDN edge cache → origin server
Each layer caches independently. A response can be fresh at the CDN but stale in the browser, or not cached at the CDN but cached in the browser. Understanding each layer's rules is essential.
Cache-Control: the master directive
The Cache-Control response header is authoritative. Common directives:
| Directive | Meaning |
|---|---|
max-age=N |
Cache this response for N seconds from the response time |
s-maxage=N |
Same as max-age but applies to shared caches (CDNs) only |
no-store |
Do not cache at all — ever |
no-cache |
Cache but revalidate before every use |
private |
Only the browser may cache; CDNs must not |
public |
Any cache may store this response |
must-revalidate |
Do not serve stale without revalidating first |
stale-while-revalidate=N |
Serve stale for up to N seconds while fetching fresh in background |
stale-if-error=N |
Serve stale for up to N seconds if origin returns an error |
immutable |
Content will never change; skip revalidation even after max-age |
A typical static asset: Cache-Control: public, max-age=31536000, immutable
A typical API response: Cache-Control: private, no-cache
Revalidation: ETag and Last-Modified
When a cached response expires, the client can ask the server "has this changed?" instead of fetching the whole thing. This is revalidation.
ETag: The server assigns an opaque token (usually a content hash) to each response version. On revalidation, the client sends If-None-Match: "<etag>". If the content has not changed, the server responds 304 Not Modified with no body — saving bandwidth. If it changed, the server responds 200 with the new body and a new ETag.
Last-Modified: An older alternative. The server sets Last-Modified: <date>. The client revalidates with If-Modified-Since: <date>. ETags are preferred because they can represent entity identity without relying on clock synchronization.
stale-while-revalidate
stale-while-revalidate is the pragmatic middle ground. It allows a cache to serve a stale response immediately (zero latency) while it fetches a fresh copy in the background. The user sees no delay; the next request gets the fresh version.
Cache-Control: max-age=60, stale-while-revalidate=300
This says: fresh for 60 seconds. Between 60–360 seconds, serve stale but refresh in background. After 360 seconds, block on revalidation.
CDN caching vs browser caching
s-maxage overrides max-age for shared caches (CDNs). This lets you set a short browser TTL (so users get updates quickly) while giving the CDN a long TTL (so it can serve millions of requests without hitting origin).
Cache-Control: public, max-age=60, s-maxage=86400
Browser caches for 60 seconds. CDN caches for a full day.
Cache invalidation at the CDN
CDNs cache by URL. The only reliable way to invalidate a CDN cache is:
- Versioned URLs:
main.abc123.js— change the hash, the URL changes, browsers and CDNs fetch fresh automatically. This is the standard approach for build artifacts. - Purge API: Most CDNs expose an API to invalidate by URL or tag. Useful for content that cannot be versioned (e.g., a product page at
/products/42). - Surrogate keys / cache tags: Tag responses with logical keys (e.g.,
product-42). On update, purge by tag rather than URL.
Vary: cache by request header
Vary: Accept-Encoding tells the cache to store separate copies for different Accept-Encoding values (gzip, br, identity). Vary: Accept-Language separates by language. Over-using Vary fragments the cache and reduces hit rates.
Common mistakes
Cache-Control: no-cache does not mean "do not cache". It means "cache but always revalidate before serving." To prevent caching entirely, use no-store.
Not setting Cache-Control at all. Browsers and CDNs apply heuristic caching rules when the header is absent. These rules vary by implementation. Always set an explicit policy.
Setting a long max-age on mutable URLs. If index.html is cached for a year but you deploy a new build, users serve stale HTML. Either use short TTLs on HTML or add build-hash invalidation.
Not separating CDN and browser TTLs. Use s-maxage for the CDN and max-age for the browser. Otherwise a long CDN TTL also forces a long browser TTL.
Tools in the wild
6 tools- serviceCloudflarefree tier
Edge CDN with cache rules, stale-while-revalidate, and per-PoP cache analytics.
- service
VCL-programmable CDN; instant purge by surrogate key — the cache nerd's CDN.
- serviceVercel Edge Cachefree tier
Built into Vercel deployments; respects `Cache-Control` + `s-maxage` from your handlers.
- libraryVarnishfree tier
Open-source HTTP accelerator with VCL — the original modern reverse-proxy cache.
- serviceRedbotfree tier
Mark Nottingham's analyzer that explains why an HTTP response will/won't be cached.
- clicurl -Ifree tier
Inspect `Cache-Control`, `ETag`, `Vary`, and `Age` headers right from the terminal.