networking · level 4

HTTP Caching

Cache-Control, ETag, stale-while-revalidate, CDNs.

225 XP

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:

  1. 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.
  2. 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).
  3. 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
  • Cloudflarefree 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.

    service
  • Built into Vercel deployments; respects `Cache-Control` + `s-maxage` from your handlers.

    service
  • Varnishfree tier

    Open-source HTTP accelerator with VCL — the original modern reverse-proxy cache.

    library
  • Redbotfree tier

    Mark Nottingham's analyzer that explains why an HTTP response will/won't be cached.

    service
  • curl -Ifree tier

    Inspect `Cache-Control`, `ETag`, `Vary`, and `Age` headers right from the terminal.

    cli