Understanding Cache-Control and ETag for efficient web caching

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

DirectiveWhat it means
max-age=NResponse is fresh for N seconds. After that, the cache treats it as stale.
s-maxage=NSame as max-age but only for shared (CDN) caches. Overrides max-age for them.
publicAny cache (browser, CDN) may store this response.
privateOnly browser caches may store this — never a CDN or other shared cache.
no-cacheCache may store the response, but must revalidate with the origin before each reuse.
no-storeDon't store the response anywhere. The "actually don't cache this" directive.
must-revalidateOnce stale, the cache must revalidate before reusing — no serving stale.
immutableThe body will never change for this URL. Browsers skip revalidation entirely. Pair with hashed asset URLs.
stale-while-revalidate=NServe stale content for up to N seconds while revalidating in the background. Most CDNs and modern browsers honor this.
stale-if-error=NIf 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-Language

Without 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.