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. |
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. |
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 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 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 a304answers 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/loginunder 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:
| 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-ageequalss-maxage.s-maxageapplies only to shared caches and overridesmax-agethere (RFC 9111 §5.2.2.10), so addingmax-agechanges nothing for CDNs — but browsers don't reads-maxage. Withoutmax-age, a private cache would get no freshness lifetime at all and revalidate on every navigation while the server kept the entry for the fullmaxAge. Both now receive the one lifetime ocache can actually promise.stale-while-revalidateis sent only with a value. The directive requires a number of seconds (RFC 5861 §3). The ISR pattern —swr: truewith nostaleMaxAge— has an unlimited stale window, which no number describes, so the directive is omitted. Downstream caches then revalidate whenmax-ageends, 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.)getMaxAgecontrols each field. A per-entrymaxAgeorstaleMaxAgeoverrides 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 asmax-age=2even 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:
| 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.
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 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— as in the example. Both credential headers vary the key, reach the handler, and appear inVary. Best when only some responses are personalized: the anonymous response still caches under its own key, and theprivateresponses 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 synthesizedetag/Cache-Control/X-Cache. Best when every response is per-user;privatethen 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 inCookieand vary the key. Same trade-off asallowAuthorization: same value, shared entry — a response that must not be shared still needsprivateorno-store.varies: [...]— the general form for identity in another header (X-Api-Key,X-Forwarded-User). Same trade-off; a truly private response still needsprivate.
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: Cookiemakes the synthesized lifetime useless downstream — keep the server-side cache, drop the advertisement. - Purge-sensitive content: if
invalidateCache/expireCacheclears your storage but callers still see stale responses, a browser or CDN in front holds its own copy that the synthesizedCache-Controllet it keep. A server-side purge never reaches it.sendCacheControl: falsestops 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
booleanorPromise<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
shouldCacherejection still gets the synthesizedCache-Control— the setup where a CDN caches what ocache does not.
#Related
headersOnlyskips storage entirely and answers conditionals from the handler's own validators.- Incremental Static Regeneration (ISR) combines
swrandmaxAgeto serve stale content during revalidation.