# Cache-Control Header: Directives, Examples and CDN Behavior

> Cache-Control directives explained: max-age, no-cache vs no-store, s-maxage, stale-while-revalidate, immutable, with nginx, Cloudflare and Next.js examples.

Source: https://howhttpworks.com/headers/cache-control
Last reviewed: 2026-10-04

> **TL;DR:** `Cache-Control` sets how long a response stays fresh and who may store it. Use `public, max-age=31536000, immutable` for hashed assets, `no-cache` for HTML you want revalidated, `private` for per-user data, and `no-store` only for responses that must not be kept.

## How a cache decides

For every stored response a cache, whether browser or CDN, asks three questions: is it still fresh (`max-age`, `s-maxage`, `Expires`), may it be reused for this request (`Vary`, `private`), and if stale, can it be revalidated (`ETag`, `Last-Modified`). `Cache-Control` drives all three. Directives are comma-separated, case-insensitive, and unknown ones must be ignored (RFC 9111 section 5.2).

```http
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
ETag: "v42"
```

## Response directives

| Directive | Effect |
| --- | --- |
| `max-age=N` | Fresh for N seconds from generation time; `Age` counts against it |
| `s-maxage=N` | Like `max-age`, but only for shared caches (CDNs, proxies); overrides `max-age` there |
| `no-cache` | May be stored, but must be revalidated with the origin before every reuse |
| `no-store` | Caches must not store any part of the request or response |
| `private` | Only a private cache (a single user's browser) may store it |
| `public` | Explicitly lets shared caches store it, including when the request carried `Authorization`; otherwise rarely needed |
| `must-revalidate` | Once stale, must not be served without successful revalidation; if the origin is unreachable the cache answers 504 |
| `proxy-revalidate` | Same as `must-revalidate`, but applies only to shared caches |
| `must-understand` | A cache may store a response with an unrecognised status code only if it understands that code; otherwise it behaves as `no-store` (RFC 9111 section 5.2.2.3) |
| `no-transform` | Intermediaries must not alter the body or `Content-Encoding`/`Content-Type`; stops proxies that recompress images or minify |
| `immutable` | Body will not change while fresh, so skip revalidation on reload (RFC 8246) |
| `stale-while-revalidate=N` | Serve stale up to N seconds past expiry while refreshing in the background (RFC 5861) |
| `stale-if-error=N` | Serve stale up to N seconds when the origin errors or is unreachable (RFC 5861) |

Request directives are `no-cache`, `no-store`, `max-age`, `max-stale`, `min-fresh`, `only-if-cached` and `no-transform`. In the browser, `fetch(url, { cache: 'no-store' })` maps onto them.

## no-cache vs no-store

The names are the most misleading in HTTP. `no-cache` means "keep it, but ask first":

```http
GET /app HTTP/1.1
If-None-Match: "v42"

HTTP/1.1 304 Not Modified
```

`no-store` means "do not keep this". RFC 9111 section 5.2.2.5 names it as the directive for sensitive content. More in [no-cache vs no-store](https://howhttpworks.com/compare/no-cache-vs-no-store).

A widespread snippet is `no-store, no-cache, must-revalidate` with `Pragma: no-cache` and `Expires: 0`. Only `no-store` does the work. `Pragma: no-cache` is defined only as a request header (RFC 9111 section 5.4) and survives for HTTP/1.0 proxies. `no-store` is an instruction to compliant caches, not a vault: it does not stop a user screenshotting a page, and a page may be restored from the browser's back/forward cache, so test logout flows explicitly.

## Freshness, heuristics and Age

With `max-age`, `s-maxage` or `Expires`, freshness is explicit. Without any of them a cache may compute a heuristic lifetime (RFC 9111 section 4.2.2), commonly 10% of the time since `Last-Modified`. A response with only `Last-Modified` can therefore be reused for hours or days without contacting the origin. Always send an explicit policy.

`Age` is how many seconds the response has already spent in caches. A CDN reporting `Age: 120` on a `max-age=300` response has 180 seconds of freshness left.

## stale-while-revalidate and stale-if-error

```http
Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=86400
```

From 0 to 60 seconds the response is served fresh. From 60 to 360 seconds it is served stale instantly while a background request refreshes it. After that the client waits for the origin. If the origin returns a 5xx within a day, the stale copy is served instead of the error. Browsers honour `stale-while-revalidate` in Chrome 75+, Firefox 68+ and Safari 14+; `stale-if-error` matters mostly to CDNs and other shared caches. CloudFront supports both directives (it caps them at the cache policy's maximum TTL), and Cloudflare supports both with two caveats: its documentation says `s-maxage` disables `stale-while-revalidate`, and `stale-if-error` is ignored when Always Online is enabled.

## Shared-cache and CDN-only control

`s-maxage` gives CDNs a longer TTL than browsers:

```http
Cache-Control: public, max-age=60, s-maxage=3600
```

RFC 9213 defines targeted fields so one response can carry separate policies per cache layer. `CDN-Cache-Control` addresses CDNs generally, and vendors add their own, such as `Cloudflare-CDN-Cache-Control` and `Surrogate-Control`. A targeted field overrides `Cache-Control` for the cache that reads it.

```http
Cache-Control: no-cache
CDN-Cache-Control: max-age=3600
```

Browsers revalidate every time while the CDN holds the page for an hour. Which header your CDN reads varies, so confirm in its documentation.

## Recipes

Hashed static assets, where the filename changes with the content:

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

HTML entry points referencing those assets:

```http
Cache-Control: no-cache
ETag: "9f2c"
```

Per-user API responses:

```http
Cache-Control: private, max-age=60
Vary: Authorization
```

Account pages, tokens, anything that must not be kept:

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

Public data, fast with background refresh:

```http
Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=3600
```

## By stack

**nginx.** `expires 1y` already emits `Expires` and `Cache-Control: max-age=31536000`. Adding a second `add_header Cache-Control` produces two `Cache-Control` headers, which caches combine; keep one source of truth. Remember `add_header` in a `location` discards all `add_header` lines inherited from the enclosing `server` block.

```nginx
location ~* \.(?:js|css|woff2|png|jpg|svg)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
    add_header Cache-Control "no-cache";
}
```

**Next.js.** Files under `/_next/static` already get `public, max-age=31536000, immutable`. Pages and route handlers need explicit values, either in the response or in config:

```javascript
// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/api/articles',
        headers: [
          { key: 'Cache-Control', value: 'public, s-maxage=60, stale-while-revalidate=300' }
        ]
      }
    ]
  }
}
```

**Cloudflare.** By default it caches by file extension (static assets), not HTML or JSON, and its Browser Cache TTL setting can override the browser-facing `max-age`. To cache HTML, create a Cache Rule ("Eligible for cache") and choose "Use cache-control header if present" or set an Edge TTL. Read `CF-Cache-Status`: `HIT`, `MISS`, `DYNAMIC` (not eligible), `BYPASS`, `REVALIDATED`, `EXPIRED`.

**CloudFront.** A cache policy sets minimum, default and maximum TTL. The origin's `max-age` is clamped between minimum and maximum, so a non-zero minimum TTL can cache responses the origin marked `no-cache`. Look for `X-Cache: Hit from cloudfront` and the `Age` header.

## Check what you are getting

```bash
curl -sI https://example.com/app.js | grep -iE '^(cache-control|age|etag|expires|vary|cf-cache-status|x-cache)'
```

```text
cache-control: public, max-age=31536000, immutable
age: 4821
cf-cache-status: HIT
```

Test the revalidation round trip:

```bash
curl -sI https://example.com/ -H 'If-None-Match: "9f2c"' | head -1
```

`HTTP/2 304` means validation works. In Chrome DevTools, "Disable cache" sends `Cache-Control: no-cache` on requests, which can hide caching bugs while DevTools is open.

## Common mistakes

- Caching personalised responses with `public`, or behind a CDN rule that ignores `Vary` and `Set-Cookie`, so one user receives another's page.
- A long `max-age` on a URL that is not versioned. Browsers will not ask again until it expires and you cannot recall the copy.
- Mixing `Expires` and `max-age` with different values. When `max-age` is present `Expires` is ignored, so keep only one.
- Omitting `Vary: Accept-Encoding` or `Vary: Origin` when the body differs, which poisons shared caches. See [Vary](https://howhttpworks.com/headers/vary).
- Using `no-cache` and expecting no stored copy. The response is stored; it is just revalidated.

## Related

- [ETag](https://howhttpworks.com/headers/etag), [Last-Modified](https://howhttpworks.com/headers/last-modified), [Expires](https://howhttpworks.com/headers/expires), [Age](https://howhttpworks.com/headers/age), [Vary](https://howhttpworks.com/headers/vary)
- [If-None-Match](https://howhttpworks.com/headers/if-none-match) and [304 Not Modified](https://howhttpworks.com/status-codes/304)
- [HTTP caching guide](https://howhttpworks.com/guides/headers-and-caching) and the [Cache builder](https://howhttpworks.com/tools/cache-builder)
