# HTTP Caching Explained: Cache-Control, ETag and 304 Not Modified

Caching is the cheapest performance win on the web, and the most common source of "why am I still seeing the old version?" It runs on two separate mechanisms that are easy to blur together. Once you can tell them apart, every caching header makes sense.

## Mechanism 1: freshness — no request at all

`Cache-Control: max-age=3600` tells a browser or CDN that this response stays **fresh** for an hour. During that hour the cache answers by itself; nothing goes over the network. In DevTools you'll see these as *(memory cache)* or *(disk cache)*.

Freshness is what makes repeat visits fast. It's also why a bad deploy can stay stuck in browsers: until the response goes stale, the browser doesn't even ask.

## Mechanism 2: validation — ask, and maybe get a 304

When a cached response is stale (or marked `no-cache`), the cache asks the server whether its copy is still good, by sending what it knows:

```http
GET /app.css HTTP/1.1
If-None-Match: "5d8c72a-1f3"
```

If nothing changed, the server answers [304 Not Modified](/http/304) with no body, and the cache reuses its copy. A few hundred bytes instead of the whole file.

The validators come from the original response:

- `ETag: "5d8c72a-1f3"` — an identifier for this exact version, sent back as `If-None-Match`.
- `Last-Modified: Tue, 14 Oct 2026 09:30:00 GMT` — sent back as `If-Modified-Since`.

## The Cache-Control directives that matter

| Directive | Meaning |
|---|---|
| `max-age=N` | Fresh for N seconds in any cache |
| `s-maxage=N` | Fresh for N seconds in shared caches (CDNs); overrides `max-age` there |
| `public` | Any cache may store it, even for authenticated requests |
| `private` | Only the user's browser may store it — not CDNs |
| `no-cache` | May be stored, but must be **revalidated** before every use |
| `no-store` | Must not be stored anywhere |
| `immutable` | Will never change; don't revalidate even on reload |
| `stale-while-revalidate=N` | Serve a stale copy for up to N seconds while refreshing in the background |

The two most confused are `no-cache` and `no-store`. `no-cache` does **not** mean "don't cache" — it means "always check first", which with ETags is cheap. `no-store` is the one that keeps a response out of caches entirely.

## A setup that works for most sites

**Static assets with a hash in the file name** (`app.3f2a1c.js`): cache forever. A new deploy produces a new file name, so staleness is impossible.

```http
Cache-Control: public, max-age=31536000, immutable
```

**HTML pages**: always revalidate, so a deploy shows up on the next load, but let unchanged pages come back as cheap 304s.

```http
Cache-Control: no-cache
ETag: "abc123"
```

**Public API responses behind a CDN**: short shared freshness, plus background refresh.

```http
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
```

**Personal data** — account pages, API responses with user data:

```http
Cache-Control: private, no-store
```

## When there's no Cache-Control at all

Caches don't just give up. For responses like [200 OK](/http/200) that are cacheable by default, browsers apply **heuristic freshness**, commonly 10% of the time since `Last-Modified`. A file last changed 30 days ago may be treated as fresh for three days. That's the hidden cause behind many "users see old code" reports. Always send an explicit `Cache-Control`.

## The Vary header

`Vary` tells caches which request headers change the response, so they store separate copies:

- `Vary: Accept-Encoding` — separate gzip and Brotli versions (servers add this automatically).
- `Vary: Origin` — needed when your [CORS](/blog/cors-errors-explained) headers depend on the caller.
- `Vary: Cookie` — effectively disables shared caching, since every user's cookies differ.

## Debugging caching

- **DevTools → Network**: the Size column shows *(disk cache)* or *(memory cache)* for fresh hits, and the Status column shows 304 for revalidations. Tick *Disable cache* to bypass everything.
- **curl** shows the real headers without any browser cache in the way: `curl -sI https://example.com/app.css`.
- **CDN headers** report hits and misses: `cf-cache-status` on Cloudflare, `x-vercel-cache` on Vercel (HIT, MISS, STALE, PRERENDER), `x-cache` on CloudFront.

To compare headers across many URLs at once, paste them into the [HTTP Status Checker](/tools/web/http-status-checker). Every header in this guide has its own reference page: [Cache-Control](/headers/cache-control), [ETag](/headers/etag), [Last-Modified](/headers/last-modified), [If-None-Match](/headers/if-none-match) and [Vary](/headers/vary).
