# How to choose Cache-Control values for private and shared caches

> Give every class of endpoint one Cache-Control value from RFC 9111, check that each route sends it, and count the origin requests a shared cache makes.

Source: https://voxgig.com/howto/choose-cache-control-directives-for-api-responses

- Audience: api-producer
- Level: intermediate
- Verified: 2026-10-15
- Published: 2026-10-15

## Short answer

Write a table that gives each class of endpoint one Cache-Control value before any handler sets a header. Shared data that may be N seconds old gets public, max-age=N. Shared data that must never be stale gets public, no-cache and an ETag. Per-user data gets private, no-cache. Regulated data and a 404 for a record that can be created get no-store, and a 5xx gets no freshness at all.

---
## You will need

Node 22 or later, and an endpoint that already sends an `ETag`. Setting `ETag` and Cache-Control on
API responses is the step before this one in the series. Verified 2026-10-15 against Node
22.22.0 and http-cache-semantics 4.3.0. Every rule below comes from
[RFC 9111](https://www.rfc-editor.org/rfc/rfc9111.html), the HTTP caching standard, rather than
from what a directive's name suggests.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [max-age fresh window](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.1) | Shared data that may be a bounded number of seconds old, such as exchange rates | A caller can read a value up to N seconds after it changed, and you cannot recall it | A stale read is a bug, or the data belongs to one caller |
| [no-cache plus `ETag` revalidation](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4) | Shared data that has to be current on every read | One origin round trip per read, though a 304 carries no body | The origin cannot take a request per read, or some staleness is fine |
| [no-store](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.5) | Regulated data, and errors that must not outlive the condition | Every read is a full origin response, so no cache absorbs a spike | The data is the same for every caller and may be cached |
| [RFC 9111 heuristic freshness](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.2) | Never by choice: it is what a cache does when you send nothing | Each cache picks its own lifetime, so two caches in one path disagree | Any response you can name a policy for |

`no-cache` with an `ETag` buys zero staleness for one round trip per read, and the round trip is
cheap because the answer is usually a 304 with no body. `max-age` removes the round trips and
pays in staleness, bounded by the number you chose. `no-store` pays in origin load on every read,
and it is the only one of the three that keeps the response out of the cache's storage. Heuristic
freshness is not a policy at all, because the cache picks the number.

## Write the table before the handlers

A directive belongs to a class of endpoint, not to a handler. Write the classes down first, one
value each, and make every route name its class. The first five rows of the sample's table:

```js title="policy.mjs"
export const CLASSES = {
  versioned: {
    path: '/schemas/v3/invoice.json',
    status: 200,
    cacheControl: 'public, max-age=31536000, immutable',
    why: 'the URL changes whenever the bytes do',
  },
  shared: {
    path: '/rates',
    status: 200,
    cacheControl: 'public, max-age=60',
    why: 'the same for every caller, and a minute of staleness is acceptable',
  },
  revalidate: {
    path: '/products/42',
    status: 200,
    cacheControl: 'public, no-cache',
    why: 'the same for every caller, and a stale price is a bug',
  },
  perUser: {
    path: '/me',
    status: 200,
    cacheControl: 'private, no-cache',
    why: 'one caller only, so the browser may keep it and a shared cache must not',
  },
  regulated: {
    path: '/me/payment-methods',
    status: 200,
    cacheControl: 'no-store',
    why: 'no cache anywhere may keep a copy',
  },
```

`public` appears on every shared row because API requests carry `Authorization`.
[Section 3.5](https://www.rfc-editor.org/rfc/rfc9111.html#section-3.5) forbids a shared cache to
reuse the answer to such a request unless the response says `public`, `s-maxage`, or
`must-revalidate`. `immutable` tells a cache not to revalidate inside `max-age`, which is safe only
because the version is in the path.[^1] `private, no-cache` lets the caller's browser keep a copy
and check it with the `ETag`, while a shared cache keeps nothing.

The difference between the two words people confuse is in the standard's own text. `no-cache`
means the response must not be used without validation, so a cache may store it. `no-store` means
a cache must not store it at all.

## Count origin requests through a shared cache

No Varnish or CDN runs inside the sample, so it models one. Storage is a `Map`, and the freshness
rules come from [http-cache-semantics](https://github.com/kornelski/http-cache-semantics)
in its shared-cache mode. Its README lists make-fetch-happen, and got through cacheable-request,
among its users. Time comes from a clock
the test moves.

```js title="cache.mjs"
export function createSharedCache({ origin, clock, defaultTtl = null, name = 'ModelCache' }) {
  class Policy extends CachePolicy {
    now() { return clock.now() }
  }
```

Each policy runs on the same endpoint: ten reads, 10 seconds apart, with the price changed at 25
seconds. The predicted origin requests were written into the demo before it ran.

```bash
node demo.mjs
```

```text output
GET /products/42 through one shared cache: ten reads 10 s apart, price changes at t=25s
policy                      origin  bodies  304s  predicted  reads of the old price
public, no-cache            10      2       8     10         0
public, max-age=60          2       2       0     2          3
no-store                    10      10      0     10         0
```

`no-cache` reached the origin on all ten reads, and eight of them came back as 304 responses
with no body. `max-age=60` reached it twice and served the previous price three times, which is the staleness
you bought. `no-store` sent ten full bodies. All three matched the prediction, which is the point
of predicting: a count that surprises you is a directive that does not mean what you thought.

## Keep a 404 from outliving the record

A 404 is heuristically cacheable. [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.1)
lists 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, and 501 as statuses a cache may
reuse with no explicit directives.[^2] How long it keeps them is the cache's choice.
[Varnish](https://www.varnish.org/docs/reference/vcl-var/) applies `default_ttl`, two minutes, to a
404 with no Cache-Control and no Expires.[^3] The demo's `defaultTtl` option models that rule.

```bash
node demo.mjs
```

```text output
GET /products/77 before the record exists, create it, read again 30 s later
404 with no directives, cache with a 120 s default
  first 404, second 404  ModelCache; hit; ttl=90
404 with no directives, http-cache-semantics alone
  first 404, second 200  ModelCache; fwd=stale; fwd-status=200; stored
404 with no-store, cache with a 120 s default
  first 404, second 200  ModelCache; fwd=uri-miss; stored
```

The first case is the support ticket: the record exists, but the cache still answers 404, with 90
seconds of freshness left, in the [Cache-Status](https://www.rfc-editor.org/rfc/rfc9211.html) syntax of
RFC 9211. The library alone recovers because its heuristic needs a Last-Modified header and the
404 has none. Your users do not choose which cache they get. `no-store` removes the choice.
If 404 traffic is heavy, `max-age=5` bounds the damage at five seconds instead.

## Leave freshness off a 5xx

A 500, 502, 503, or 504 is not heuristically cacheable, so a compliant cache stores it only when
the response carries explicit freshness or `public`. [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-16.2.2)
says any final status can be cached once it has explicit freshness. A middleware that stamps the
route's `max-age=300` on every response does that to the error too.
[CloudFront](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/HTTPStatusCodes.html)
caches a 503 for the longer of its error caching minimum TTL, 10 seconds by default, and the
origin's `max-age`. The 503 can then be served for up to five minutes after the outage ends. Send
`Retry-After` on the 5xx and no freshness directives.

## Check it worked

Run the header check. It requests one URL per class and compares directive sets, so
`max-age=60, public` matches `public, max-age=60`, and it exits non-zero on a mismatch.

```bash
node check-headers.mjs
```

```text output
versioned   /schemas/v3/invoice.json  200  public, max-age=31536000, immutable  ok
shared      /rates                    200  public, max-age=60                   ok
revalidate  /products/42              200  public, no-cache                     ok
perUser     /me                       200  private, no-cache                    ok
regulated   /me/payment-methods       200  no-store                             ok
notFound    /products/77              404  no-store                             ok
serverError /reports/daily            503  (none)                               ok
```

Point it at a deployed API with `BASE_URL`, which runs the same comparison against what your
gateway or framework sends rather than what the handler meant to send.

```bash
BASE_URL=https://api.example.com node check-headers.mjs
```

The test runs the header table, the three origin counts, both 404 cases, and a check that an
authenticated request is stored only when the response says `public`.

```bash
node --test cache.test.mjs
```

```text output
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 337.398354
```

## When it goes wrong

The cache's hit count stays at zero. Your callers send `Authorization` and the response lacks
`public`, so a shared cache stores nothing. [Cloudflare's cache status
documentation](https://developers.cloudflare.com/cache/concepts/cache-responses/) lists the same
rule as a reason for `BYPASS`. Add `public` to the rows whose data is the same for every caller.

Every read reaches the origin under `max-age=60, must-revalidate`. A cache built on
http-cache-semantics 4.3.0 validates every read with the origin when it sees `must-revalidate`, not
only once the response is stale.

```bash
node demo.mjs
```

```text output
public, max-age=60, must-revalidate: 10 origin requests, RFC 9111 predicts 2
```

[Section 5.2.2.2](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.2) applies the
directive to stale responses only, so the library is stricter than the standard here, and the
model inherits that. The standard reserves the directive for responses where a failed validation
could cause incorrect operation, so drop it from the rest.

## When not to do this

Do not mark a URL `immutable` unless the URL changes when the bytes do. A year of freshness on
`/rates` pins one value in every browser that fetched it, because a browser holding a fresh copy
does not ask the origin again until the year is up.

Do not treat `no-store` as protection for regulated data. RFC 9111 says itself that the directive
is not a reliable or sufficient mechanism for privacy, since a compromised cache can ignore it.
Keep the data out of URLs and logs, and encrypt it in transit.

Do not reach for `s-maxage` to give the edge a different lifetime unless you have read what else
it does. It carries the semantics of `proxy-revalidate` for shared caches, which forbids them to
serve the response stale. A stale-while-revalidate window added later is then ignored by a cache
that follows the standard, as
[Cloudflare documents](https://developers.cloudflare.com/cache/concepts/revalidation/) that it
does.

Do not build a seven-row table for an internal API that no shared cache fronts and whose clients
never cache. One default of `no-store` is cheaper to review, and the table earns its keep only
where a cache can act on it.

## Related how-tos

- [Build cache keys that include the caller and Vary headers](/howto/cache-keys-that-include-the-caller)

- [Share an API response cache across many workers with Redis](/howto/share-a-response-cache-across-workers-with-redis)

- [Serve stale API responses and revalidate in the background](/howto/serve-stale-api-responses-while-revalidating)

## Last verified

Verified 2026-10-15 against Node 22.22.0 and http-cache-semantics 4.3.0. Every output block is what
the command preceding it printed. The shared cache is a model built on http-cache-semantics, not
Varnish or a CDN: the Varnish, Cloudflare, and CloudFront behavior is quoted from their
documentation, not measured.

[^1]: [RFC 8246](https://www.rfc-editor.org/rfc/rfc8246.html), which defines `immutable`, opens
with a newspaper's front-page photo and the reload button. A browser refreshing a page could send
hundreds of conditional requests for its parts, each answered with a 304, because nothing told it
that a versioned URL never changes. The fix is a single word in a header, and it needed a
standards-track RFC because a browser has no other way to learn that a URL is versioned.

[^2]: Heuristic freshness once came with a rule about query strings. RFC 2616 forbade a cache to
compute heuristic freshness for a URI containing a question mark.
[RFC 9111](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.2) retires that rule with one
sentence of history: in practice, this has not been widely implemented. Origin servers that want
no caching are encouraged, in the same paragraph, to send explicit directives instead.

[^3]: A 404 that says nothing is kept for two minutes by
[Varnish](https://www.varnish.org/docs/reference/varnishd/), for three minutes by
[Cloudflare](https://developers.cloudflare.com/cache/concepts/default-cache-behavior/) once a rule
makes the URL eligible, and for ten seconds by
[CloudFront](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/HTTPStatusCodes.html).
Each figure sits in its vendor's
documentation with no derivation beside it, which leaves the origin's own header as the only
number anyone chose for your API.