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:

CheckWhy
Status not in 200, 203, 301, 308Only these are complete, reusable representations. See the status allowlist.
A Cache-Control opt-out from the handlerno-store, private, no-cache, or a zero shared lifetime (s-maxage when present, else max-age). See 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 keyVary says responses are not interchangeable, but ocache keys only on varies and Cookie under allowCookies. Storing Vary: Accept-Language without varies: ["accept-language"] would replay the first language to everyone. Make the two lists agree.
A missing bodyNothing 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 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:

OptionsSynthesized 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 patternswr: 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 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 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:

DirectiveMeaning
no-storeNever store this anywhere.
privateNot in a shared cache — and ocache is one.
no-cacheMust not be reused without revalidating first.
max-age=0 / s-maxage=0Stale 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.

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 are removed before the handler runsAuthorization, 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 — 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 — 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.

    const dashboard = defineCachedHandler(renderDashboardFor, {
      maxAge: 300,
      shouldBypassCache: (event) => event.req.headers.has("authorization"),
    });
  • allowCookies: [...] — 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: [...] — 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:

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 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:

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 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:

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.