1. Home
  2. Guides
  3. Cache-Control and ETag, Explained

How HTTP caching works, and what each header actually controls

Cache headers are the difference between a site that feels instant and one that feels broken. They are also the number one source of the bug where a user sees somebody else’s data.

By Enrique M. · 9 min read · Last reviewed 2026-10-18

The two independent decisions

Every cache decision is two questions: may this response be stored, and may it be reused without asking the origin again. The first is answered by cache directives, and the second by freshness information such as `Expires`, `max-age` or an `ETag`.

A response can be storable but stale, which is what `max-age=0, must-revalidate` means: the browser may keep a copy, but it has to check before using it. That combination is what you want for a personalised page.

The Cache-Control directives that matter

The full grammar is large, but almost every real application uses five or six of them. The difference between them is frequently the difference between a bug and a working site.

  • `max-age=3600` — fresh for an hour. The browser does not ask again within that window.
  • `no-cache` — store it, but revalidate before every use. The name is misleading; it does not mean "do not store".
  • `no-store` — do not store it at all. The only correct directive for a response containing a secret.
  • `private` — only the browser may store it, not a shared cache such as a CDN.
  • `public` — a shared cache may store it. Correct for a public asset, wrong for a personalised page.
  • `must-revalidate` — once stale, do not serve it without checking with the origin.
  • `immutable` — the response will not change, so never revalidate it. Safe for a file with a hash in its name.

ETags and conditional requests

An ETag is a validator: an opaque string that changes when the resource changes. It does not have to be a hash, but a strong ETag has to change whenever the representation does, and a weak ETag may be shared across semantically equivalent representations.

The browser sends it back as `If-None-Match`, and the server answers with `304 Not Modified` and no body. That is a conditional request, and it is how a page can be checked for freshness without transferring the page again.

The mistake that causes the "user sees another user’s data" bug is caching a personalised response on a shared cache without `private`. A CDN will then serve the first user’s HTML to everyone until it expires.

ETag: "a1b2c3"
Cache-Control: private, max-age=0, must-revalidate

# The browser revalidates with:
If-None-Match: "a1b2c3"

# The server answers:
304 Not Modified

What Vary is for

`Vary` tells a cache which request headers affect the response. A page that renders differently depending on `Accept-Encoding` has to say `Vary: Accept-Encoding`, otherwise the cache may store the compressed version and serve it to a client that cannot read it.

A page that varies by `Accept-Language` or by a cookie needs the same treatment. The cost of a wrong `Vary` is high, and the cost of a right one is a few bytes and a lower hit rate.

A sensible default for a static site

For a file with a content hash in its name, the headers are simple and should be aggressive, because the URL changes when the content changes. For the HTML document itself, the safe default is a short lifetime with revalidation, because the document usually points at the hashed assets.

# Hashed asset — cache forever
Cache-Control: public, max-age=31536000, immutable

# The HTML document — revalidate every time
Cache-Control: public, max-age=0, must-revalidate

What to take away

  • Storing and reusing are two separate decisions, answered by different headers.
  • `no-cache` means "revalidate", not "do not store". `no-store` is the one that means that.
  • A personalised response on a shared cache needs `private`, or a CDN will serve it to the wrong person.
  • `immutable` is only safe when the URL changes with the content.

Related tools