HTTP caching done well saves bandwidth, server load, and round-trip time at once. Done badly, it ships stale content or hits the origin on every request. The two headers that decide which way it goes are Cache-Control and ETag — and the directives that look most similar (no-cache vs no-store) mean the opposite of what most people guess.
This is grounded in RFC 9111, which superseded RFC 7234 in 2022 and is the current spec for HTTP caching.
Cache-Control directives that matter
| Directive | What it means |
|---|---|
max-age=N | Response is fresh for N seconds. After that, the cache treats it as stale. |
s-maxage=N | Same as max-age but only for shared (CDN) caches. Overrides max-age for them. |
public | Any cache (browser, CDN) may store this response. |
private | Only browser caches may store this — never a CDN or other shared cache. |
no-cache | Cache may store the response, but must revalidate with the origin before each reuse. |
no-store | Don't store the response anywhere. The "actually don't cache this" directive. |
must-revalidate | Once stale, the cache must revalidate before reusing — no serving stale. |
immutable | The body will never change for this URL. Browsers skip revalidation entirely. Pair with hashed asset URLs. |
stale-while-revalidate=N | Serve stale content for up to N seconds while revalidating in the background. Most CDNs and modern browsers honor this. |
stale-if-error=N | If the origin returns an error, serve stale up to N seconds old. Resilience knob. |
The single most common mistake: using Cache-Control: no-cache thinking it means "don't cache." It doesn't. It means "cache, but revalidate before each use." If you actually want no caching, use no-store.
ETag: validating staleness
When Cache-Control says a resource has gone stale, the cache faces a choice: re-download the whole body, or just ask the origin "is this still current?". ETag is what makes the second path cheap.
The server returns a unique opaque token with the response:
1HTTP/1.1 200 OK
2Cache-Control: max-age=3600
3ETag: "abc123"
4Content-Type: text/html
5Content-Length: 1234
6
7<html>...</html>When the cache wants to revalidate, it sends:
1GET /resource HTTP/1.1
2If-None-Match: "abc123"If the resource hasn't changed, the origin responds with a body-less 304 Not Modified and the cache reuses what it already has. If it has changed, the origin sends the new body with a new ETag.
Strong vs weak ETags
A leading W/ marks the ETag as weak — meaning "the resource is semantically equivalent" rather than byte-identical:
1ETag: W/"abc123"Strong ETags are required for byte-range requests; weak ETags are fine for cache validation and let backends tolerate cosmetic differences (compression, whitespace, etc.).
ETag is generally preferred over Last-Modified. Last-Modified only has second-level resolution and breaks down for resources that change more than once per second.
Vary: the part you'll forget
Vary tells caches which request headers are part of the cache key. The two you'll actually use:
1Vary: Accept-Encoding
2Vary: Accept-LanguageWithout Vary: Accept-Encoding, a CDN will happily serve a gzip body to a client that asked for identity, or vice versa. Skip it and you'll see weird intermittent corruption that's hard to reproduce.
Putting it together
A typical setup for a hashed JS bundle:
1Cache-Control: public, max-age=31536000, immutable
2ETag: "v123abc"A year of cache, never revalidate, mirror to CDNs. Safe because the URL itself encodes the version.
For an HTML page that changes:
1Cache-Control: public, max-age=0, must-revalidate
2ETag: "page-v17"Always check with the origin, but if the ETag matches, accept a 304 and skip the body.
Why both, instead of just ETag
ETag alone forces a round-trip on every request — even if the answer is always 304, you're still paying the connection and the latency. Cache-Control lets you skip the request entirely while the response is fresh; ETag makes the validation cheap when freshness expires. They cover different parts of the problem, and the gain is multiplicative.