
# Cache-Control & Eligibility

`defineCachedHandler` decides two separate things for every response: whether to **store** it, and what `Cache-Control` to **advertise** downstream. This page covers both — the built-in eligibility rules, the response-side opt-outs, and the hooks that adjust them.

## What is never cached

Before storing a response, ocache applies these checks. A response that fails **any** of them is still returned to the caller — it is just never stored or served from the cache:

| Check                                                                  | Why                                                                                                                                                                                                                                                                                                                          |
| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Status not in `200`, `203`, `301`, `308`                               | Only these are complete, reusable representations. See [the status allowlist](#the-status-allowlist).                                                                                                                                                                                                                        |
| A `Cache-Control` opt-out from the handler                             | `no-store`, `private`, `no-cache`, or a zero **shared** lifetime (`s-maxage` when present, else `max-age`). See [Private / non-cacheable responses](#private--non-cacheable-responses).                                                                                                                                      |
| `Vary: *`                                                              | The strongest "do not share" signal short of `no-store`. No stored response can match such a request (RFC 9111 §4.1), and ocache keeps one entry per key.                                                                                                                                                                    |
| A `Vary` naming a header outside the cache key                         | `Vary` says responses are not interchangeable, but ocache keys only on [`varies`](/docs/handler#varying-by-request-headers) and `Cookie` under [`allowCookies`](/docs/cookies). Storing `Vary: Accept-Language` without `varies: ["accept-language"]` would replay the first language to everyone. Make the two lists agree. |
| A missing body                                                         | Nothing to replay. A zero-_byte_ body is different — an empty `200` is valid and caches normally.                                                                                                                                                                                                                            |
| An `etag` or `last-modified` equal to the literal string `"undefined"` | The typical result of stringifying a missing value. It would break conditional requests, so it is rejected. A genuinely omitted header is absent and never reaches this check.                                                                                                                                               |

These checks always apply; you cannot turn them off.

### The status allowlist

The allowlist is explicit — it is not "everything that isn't an error":

- `4xx` / `5xx` — an error is not a representation.
- `201`, `202`, `300` — an operation result or an unresolved choice.
- `204`, `205`, `304` — nothing to replay; in particular a `304` answers _one client's_ conditional request.
- `206` — the partial body is valid only for the requested range.
- `302`, `303`, `307` — temporary redirects can be specific to one request. A stored auth redirect to `/login` under an anonymous key would be served to every request for that URL.

### Rejected responses get no synthesized `Cache-Control`

When the built-in checks reject a response, ocache also does **not** synthesize `Cache-Control` for it. A `500` goes out without a synthesized lifetime — a shared cache in front of your handler must not keep the error for `maxAge + staleMaxAge` while ocache re-runs the handler on every request. The same holds for a `Vary: *` rejection or an out-of-key `Vary`, which might carry no `Cache-Control` of their own. A handler opt-out needs no special handling: the handler already set the header, and ocache never overrides a handler-set `Cache-Control` for any status.

[`shouldCache`](#custom-eligibility-shouldcache) is the one exception: when _your_ hook rejects a response, the synthesized lifetime is still advertised. That supports the deliberate setup where ocache does not store a response but a CDN may.

## What is advertised

When the handler sets no `Cache-Control`, ocache synthesizes one from the lifetime it actually enforces for the entry:

| Options                                       | Synthesized `Cache-Control`                           |
| --------------------------------------------- | ----------------------------------------------------- |
| `{ maxAge: 60 }`                              | `max-age=60`                                          |
| `{ maxAge: 60, swr: true }`                   | `max-age=60, s-maxage=60`                             |
| `{ maxAge: 60, swr: true, staleMaxAge: 600 }` | `max-age=60, s-maxage=60, stale-while-revalidate=600` |
| `{ maxAge: 0 }`                               | `max-age=0` — a zero lifetime, so nothing is stored   |

Details worth knowing:

- **`max-age` equals `s-maxage`.** `s-maxage` applies only to shared caches and overrides `max-age` there (RFC 9111 §5.2.2.10), so adding `max-age` changes nothing for CDNs — but browsers don't read `s-maxage`. Without `max-age`, a private cache would get no freshness lifetime at all and revalidate on every navigation while the server kept the entry for the full `maxAge`. Both now receive the one lifetime ocache can actually promise.
- **`stale-while-revalidate` is sent only with a value.** The directive requires a number of seconds (RFC 5861 §3). The [ISR pattern](/docs/isr) — `swr: true` with no `staleMaxAge` — has an _unlimited_ stale window, which no number describes, so the directive is omitted. Downstream caches then revalidate when `max-age` ends, and ocache answers from its stale copy while refreshing in the background: ISR still works, it just isn't delegated downstream. (Before v1, ocache sent the directive without a value; compliant caches must ignore that form.)
- **[`getMaxAge`](/docs/functions#dynamic-ttl) controls each field.** A per-entry `maxAge` or `staleMaxAge` overrides the static option in the header, exactly as it does in the freshness check and storage TTL. A 2-second window from the hook is advertised as `max-age=2` even with `{ maxAge: 3600 }` — the downstream lifetime always matches the enforced one.

[`sendCacheControl: false`](#suppress-synthesized-freshness-sendcachecontrol) disables synthesis. A handler-set `Cache-Control` is never changed.

## Private / non-cacheable responses

To keep a response out of the cache from inside the handler, set a `Cache-Control` opt-out. The response goes out with the header unchanged but is never stored or served from cache:

| Directive                  | Meaning                                        |
| -------------------------- | ---------------------------------------------- |
| `no-store`                 | Never store this anywhere.                     |
| `private`                  | Not in a shared cache — and ocache is one.     |
| `no-cache`                 | Must not be reused without revalidating first. |
| `max-age=0` / `s-maxage=0` | Stale the moment it was produced.              |

Directives are **parsed**, not substring-matched: `max-age=0600` means 600 seconds, and a `0` in some other directive (`stale-while-revalidate=0`) is not a zero lifetime.

`s-maxage` overrides `max-age` for shared caches (RFC 9111 §5.2.2.10), and ocache is a shared cache — when both are set, only `s-maxage` controls it. `public, max-age=0, s-maxage=600` is stored for 600 seconds (browsers revalidate, shared caches keep it); `public, s-maxage=0, max-age=600` is not stored.

> [!NOTE]
> The synthesized header follows the same rule. `maxAge: 0` — or a `getMaxAge` result of zero — advertises a zero lifetime (`max-age=0`, plus `s-maxage=0` with `swr`) and stores nothing. An omitted option or explicit `undefined` uses the default (`maxAge: 1`); only an actual `0` opts out.

```ts
const dashboard = defineCachedHandler(
  async (event) => {
    // Readable only because of `allowAuthorization` below — see the warning
    const user = await getUser(event.req.headers.get("authorization"));
    if (!user) {
      return new Response("Please log in", { status: 401 });
    }
    // Personalized — never cache this one
    return new Response(renderDashboard(user), {
      headers: {
        "content-type": "text/html",
        "cache-control": "private",
      },
    });
  },
  {
    maxAge: 300,
    allowAuthorization: true, // the credential varies the key *and* reaches the handler
  },
);
```

Any **other** `Cache-Control` from the handler passes through untouched. Synthesis happens only when the handler sets none.

### The handler has to know who is asking

> [!WARNING]
> **A `private` response is only private when the handler can identify the caller.** For cacheable requests, [headers the cache key does not cover](/docs/handler#headers-the-handler-cant-see) are removed **before the handler runs** — `Authorization`, `Proxy-Authorization`, the full `Cookie` header, and other identity headers such as `X-Api-Key` or a proxy-provided `X-Forwarded-User`. Without `allowAuthorization`, the example above cannot identify anyone and renders the anonymous variant for every caller. Here the anonymous variant is a `401`, so the route merely breaks for everyone. It is worse when the anonymous branch returns a public `200`: that page gets stored under the shared key and served to logged-in users, and the `private` branch never runs. The opt-out itself works — the problem is a handler that cannot tell callers apart.

The rule stands: **a handler reads exactly what the key covers**. For per-user responses, put the caller identity in the key or keep the request out of the cache. Per route, four options:

- **[`allowAuthorization: true`](/docs/handler#credential-headers)** — as in the example. Both credential headers vary the key, reach the handler, and appear in `Vary`. Best when only _some_ responses are personalized: the anonymous response still caches under its own key, and the `private` responses cost only a key computation since they are never stored.
- **[`shouldBypassCache`](/docs/handler#cacheable-requests)** — keep those requests fully out of the cache. A bypassed request reaches the handler **unchanged** — credentials, cookies, full query, body — and the live response is returned without storage, request combining, or synthesized `etag`/`Cache-Control`/`X-Cache`. Best when _every_ response is per-user; `private` then only instructs browsers and CDNs.

  ```ts
  const dashboard = defineCachedHandler(renderDashboardFor, {
    maxAge: 300,
    shouldBypassCache: (event) => event.req.headers.has("authorization"),
  });
  ```

- **[`allowCookies: [...]`](/docs/cookies)** — the same opt-in for session-cookie auth. Only listed names stay in `Cookie` and vary the key. Same trade-off as `allowAuthorization`: same value, shared entry — a response that must not be shared still needs `private` or `no-store`.
- **[`varies: [...]`](/docs/handler#varying-by-request-headers)** — the general form for identity in another header (`X-Api-Key`, `X-Forwarded-User`). Same trade-off; a truly private response still needs `private`.

> [!IMPORTANT]
> An opt-out controls only what ocache **stores** — it does not partition the key. Concurrent requests are still combined by cache key, so two callers on one key receive the result of one handler call even when nothing is stored. Per-user responses need per-user keys (`allowAuthorization`, `varies`, `allowCookies`) — or a bypass, which skips keying and combining entirely.

> [!NOTE]
> ocache currently rejects `no-cache` instead of storing-and-always-revalidating. RFC 9111 §5.2.2.4 permits storage if every reuse is revalidated with the origin first, but ocache has no foreground revalidation path yet, so storing would gain nothing over not storing.

## Never serve this stale (`must-revalidate`)

`must-revalidate` is **not** an opt-out — it limits stale serving, not storage. The response is stored and served normally while fresh, but never while stale, even with `swr: true` and a large `staleMaxAge`. Once expired, the next request revalidates in the **foreground** and waits:

```ts
const handler = defineCachedHandler(
  async () => {
    return new Response(await renderPrices(), {
      headers: { "cache-control": "public, max-age=60, must-revalidate" },
    });
  },
  { maxAge: 60, swr: true, staleMaxAge: 600 }, // SWR everywhere else, never for this response
);
```

Internally the entry gets `staleMaxAge: 0` — the same per-entry mechanism [`getMaxAge`](/docs/functions#dynamic-ttl) uses — so the directive affects only that response, not the whole handler.

## Suppress synthesized freshness (`sendCacheControl`)

Set `sendCacheControl: false` when ocache should store a response **without** advertising a downstream freshness lifetime. Storage, SWR, and `etag` all keep working; only the synthesized header disappears:

```ts
const handler = defineCachedHandler(myHandler, {
  maxAge: 60,
  swr: true,
  sendCacheControl: false, // no synthesized downstream freshness lifetime
});
```

This controls only the header ocache _creates_. A handler-set `Cache-Control` still passes through, and `Vary` is unaffected (it describes the response, not a cache directive). It is not a complete downstream storage policy: without an explicit directive such as `no-store`, browsers and CDNs may still store the response under their own heuristics — set `Cache-Control` on the response for an explicit policy.

Typical uses:

- A cookie-keyed route whose [`Vary: Cookie`](/docs/cookies) makes the synthesized lifetime useless downstream — keep the server-side cache, drop the advertisement.
- Purge-sensitive content: if `invalidateCache` / `expireCache` clears your storage but callers still see stale responses, a browser or CDN in front holds its own copy that the synthesized `Cache-Control` let it keep. A server-side purge never reaches it. `sendCacheControl: false` stops feeding it a lifetime — though again, it does not forbid storage.

## Custom eligibility (`shouldCache`)

`shouldCache` adds your own rejection rule on top of the built-in checks. It receives the serialized response; return `false` to skip caching (the response is still returned to the caller). For example, the built-ins allow permanent redirects (`301`/`308`) — this keeps them out:

```ts
const handler = defineCachedHandler(myHandler, {
  maxAge: 60,
  shouldCache: (res) => res.status < 300,
});
```

Behavior:

- **AND-combined with the built-ins.** It can only shrink what is cached, never force caching of a response a built-in check rejects.
- **May be async** — return `boolean` or `Promise<boolean>`.
- **Runs on reads too.** It gates both storing a fresh response and serving a stored one, including the stale-while-revalidate decision. Keep it fast and use only the entry it receives.
- **Fails closed.** A throwing hook counts as "not cacheable" and reaches `onError`.
- **Does not suppress advertisement.** Unlike the built-in checks, a `shouldCache` rejection still gets the synthesized `Cache-Control` — the setup where a CDN caches what ocache does not.

## Related

- [`headersOnly`](/docs/handler#headers-only-mode) skips storage entirely and answers conditionals from the handler's own validators.
- [Incremental Static Regeneration (ISR)](/docs/isr) combines `swr` and `maxAge` to serve stale content during revalidation.
