Caching HTTP Handlers
defineCachedHandler wraps an HTTP handler and caches its Response. It works with any runtime that provides the standard Request and Response APIs — the event is just { req: Request; url?: URL }, with no framework attached.
import { defineCachedHandler } from "ocache";
const handler = defineCachedHandler(
async (event) => {
const url = event.url ?? new URL(event.req.url);
const product = await db.getProduct(url.pathname.split("/").pop());
return Response.json(product);
},
{
maxAge: 300, // fresh for 5 minutes
swr: true, // serve stale while revalidating in the background
staleMaxAge: 600, // ...for up to 10 minutes past expiry
},
);
// Call it with any { req, url? } event
const res = await handler({ req: new Request("https://example.com/p/42") });The first request runs your handler and stores the response; later requests are served from the cache until the entry expires. All function-level options — lifetimes, storage, keys, deduplication — apply here too.
Note
maxAge defaults to 1 second. Set it explicitly for anything you actually want cached.
#What you get
- Cache keys — built from the handler name, the request origin, the URL path, and the method — plus the query parameters and headers you opt in to. Neither the query string nor any header varies the key until you declare it.
- Conditional requests — a
304 Not Modifiedwhen the client sends a matchingif-none-matchorif-modified-since. - Response headers —
etagandcache-controlare synthesized when the handler does not set them.last-modifiednever is: the time an entry was filled is not the time the content changed. Set it yourself when the handler knows the real modification time. Vary— the headers that vary the response are advertised downstream: everything invaries, plusCookieunderallowCookies. A response whose ownVarynames an undeclared header is refused storage.- Binary responses — images, tiles, and other non-text bodies are cached and replayed byte for byte.
- Streaming fills — opt in with
streamand the request that fills an entry receives the body as it is produced. See Streaming the first response. - A resolution deadline — a handler or upstream that never responds cannot hold its key forever. See Resolution deadline.
#Cacheable requests
Only GET and HEAD requests are cacheable. Every other method — POST, PUT, DELETE, and so on — bypasses the cache and reaches the handler unchanged.
A request with a Range header also bypasses, for any method: a range response is valid only for the request that specified that range. Stored under a range-less key, one caller's partial body would reach everyone — and buffering a partial body has no benefit. Your handler processes range requests untouched.
const handler = defineCachedHandler(myHandler, { maxAge: 60 });
await handler({ req: new Request(url) }); // GET → cached
await handler({ req: new Request(url, { method: "POST", body }) }); // POST → runs the handler, body intactImportant
A bypassed response passes through exactly as the handler returned it: the body is not buffered (streaming keeps working), no cache headers are synthesized, and no 304 is decided.
#GET and HEAD cache separately
A HEAD response is a different representation — a compliant framework strips its body. If both methods shared one entry, a HEAD request could store a body-less response that later GET requests would receive as a blank page. Each method therefore gets its own entry, handler call, and TTL.
The method stays part of the key even with a custom getKey: your key identifies which content to cache, and ocache adds the GET/HEAD separation on top. GET keys carry no method component, so they do not change.
#Bypassing more requests
shouldBypassCache is OR-combined with the built-in checks — it can only bypass more requests, never force another method or a range request into the cache:
const handler = defineCachedHandler(myHandler, {
maxAge: 60,
// Skip the cache for logged-in users
shouldBypassCache: (event) => event.req.headers.has("authorization"),
});A request excluded this way behaves like a POST: it reaches the handler unchanged — Authorization header, cookies, and full query string included. That makes shouldBypassCache an alternative to allowAuthorization: the handler can read the credential because the response is never stored or shared.
#Cache key name
Every key contains a name, resolved exactly like a cached function's: opts.name || handler.name || anon_<hash(handler)>. The name is what keeps two handlers on one shared storage from colliding on the same path.
const storage = createMemoryStorage();
// Named handlers → "/cache:handlers:dashboard:..." and "/cache:handlers:profile:..."
const dashboard = defineCachedHandler(
async function dashboard(event) {
return renderDashboard(event);
},
{ maxAge: 60, storage },
);
const profile = defineCachedHandler(
async function profile(event) {
return renderProfile(event);
},
{ maxAge: 60, storage },
);Important
The fallback hashes the handler source. Handlers from one factory have identical source but different closed-over variables — they share a name, and on shared storage they share entries. One tenant can then receive another tenant's cached response. Pass an explicit name or getKey when you build handlers in a loop or factory.
// ✗ both handlers key identically
const make = (tenant) => defineCachedHandler(() => render(tenant), { maxAge: 60, storage });
// ✓ named per instance
const make = (tenant) =>
defineCachedHandler(() => render(tenant), { maxAge: 60, name: `page-${tenant}`, storage });The source hash is stable across restarts, so generated keys work with persistent backends.
#Multi-host deployments
The request origin — scheme, host, and port — is part of the generated key. A handler serving several hostnames (a virtual-host setup) stores a separate entry per host: https://a.example/home and https://b.example/home never share a cached rendering, and neither do different schemes or ports.
The origin comes from event.url, falling back to new URL(event.req.url) — the value your adapter resolved, not the raw Host request header. Some adapters build the URL from Host, in which case they match; a reverse proxy in front of your app must still be trusted to normalize Host and X-Forwarded-Host.
Because only the resolved origin is keyed, ocache also rewrites the Host header your handler sees to the host of that origin. A handler that builds canonical links or absolute URLs from Host therefore builds them from a value the key covers, on every adapter. Declare varies: ["host"] if you need the raw header — it then joins the key and appears in Vary.
Note
A custom getKey replaces the entire generated key, origin included. If your handler renders host-dependent content — canonical links, absolute asset URLs, Location — add the origin to your key.
#Varying by request headers
By default the cache key depends only on the request origin and URL path: all clients on one host share one entry. List header names in varies to cache one variant per header value:
const handler = defineCachedHandler(
async (event) => {
const lang = event.req.headers.get("accept-language") ?? "en";
return new Response(renderPage(lang), {
headers: { "content-type": "text/html" },
});
},
{
maxAge: 300,
varies: ["accept-language"], // one cached entry per language
},
);These names are also added to the response Vary header (deduplicated case-insensitively; a handler-set Vary: * is left alone), so downstream caches and CDNs store each variant separately too. allowCookies likewise adds Cookie to Vary — the key uses only the allowed cookie subset, but Vary cannot express a subset. allowQuery adds nothing to Vary: query parameters live in the URL, which downstream caches already key on.
Important
A Vary set by your handler must only name headers the key covers. Vary: Accept-Language correctly says responses for different languages are not interchangeable — but ocache keys only on varies (and Cookie under allowCookies). A response naming any other header is not stored and gets no synthesized Cache-Control; otherwise the first language would be replayed to all others, with a Vary downstream caches would trust. Add the header to varies to cache it correctly:
defineCachedHandler(
(event) =>
new Response(renderPage(event.req.headers.get("accept-language")), {
headers: { vary: "Accept-Language" }, // ocache merges/dedups its own copy in
}),
{ maxAge: 300, varies: ["accept-language"] }, // ...and keys on it, so this caches
);Tip
The handler may read varying headers. Their values are in the key, so every distinct value gets its own entry — rendering from them is safe. That extends to credentials: listing cookie or authorization in varies is a broad opt-in that keys on the raw header and forwards it to the handler. These headers are removed only when you do not list them. See Cookies and Credential headers.
#Headers the handler can't see
For cacheable requests, a handler can read exactly the headers the cache key covers. All other request headers are removed before the handler runs — event.req.headers.get(...) returns null for them.
defineCachedHandler(
(event) => {
const tenant = event.req.headers.get("x-api-key"); // null — not in `varies`
return Response.json(loadTenant(tenant));
},
{ maxAge: 300 },
);This rule stops a handler from rendering content the key does not distinguish. Without it, the first caller's tenant page could be stored under the shared key, advertised to browsers and CDNs with a synthesized max-age and no Vary, and replayed to everyone for the rest of the TTL. The same risk applies to Accept, Accept-Language, Origin copied into a CORS header, and X-Forwarded-Host / X-Forwarded-Proto — behind a proxy, the keyed host may be the internal one, so a forwarded host reaching the handler would merge tenants into one entry.
Two fixes, per header:
varies: ["x-api-key"]— the header joins the key, reaches the handler, and is advertised inVary. One entry per distinct value.shouldBypassCache— keep those requests out of the cache entirely. A bypassed request reaches the handler unchanged.
The query string follows the same rule and is removed unless allowQuery covers it.
There are no exemptions: every uncovered header is removed, including If-None-Match, If-Modified-Since, and the trace headers traceparent, tracestate, X-Request-Id, and X-Correlation-Id. Host is the one rewritten name — it stays visible, but carries the resolved origin's host, the value the key covers. User-Agent is not exempt either: device- or bot-specific output needs its own keys, so add it to varies if you render from it.
Warning
The rule is one-directional on purpose: a value the key does not cover must never reach the response. If a handler rendered such a value into a cacheable 200, one caller's value would be stored and served to every later caller for the full lifetime. Removing the header makes that impossible instead of trusting the handler not to do it.
Conditional requests still work: the validators are captured from the original request before narrowing, and ocache answers 304s itself — see Conditional requests & 304. Your handler cannot do its own conditional handling, and code that propagates trace context must read it from your framework's event or middleware, which ocache never touches.
Note
Narrowing applies only to cacheable (GET/HEAD) requests, and only to the handler's view — middleware, framework, and runtime still see the original request. Narrowing replaces event.req (and event.url when the request carries a query that query filtering rewrites); if your framework exposes either as read-only, ocache cannot narrow, reports the error through onError, and serves the request as a bypass: the handler gets the original request, and the response is neither stored nor given cache headers.
#Credential headers
Authorization and Proxy-Authorization do not participate in caching by default. For cacheable requests both are removed before the handler runs, like a cookie missing from allowCookies — so a handler cannot render per-user content from a credential the key does not carry, and store it under a shared anonymous key.
If the handler needs the credential, opt in with allowAuthorization:
const handler = defineCachedHandler(
async (event) => {
const token = event.req.headers.get("authorization");
return Response.json(await loadDashboard(token));
},
{
maxAge: 60,
allowAuthorization: true, // keyed per credential, advertised in `Vary`, visible to the handler
},
);The opt-in adds both header names to varies: they vary the key, reach the handler, and appear in the response Vary. Listing authorization in varies yourself has the same effect.
Caution
allowAuthorization caches one entry per credential value, shared by every client that sends that value. Concurrent requests with the same token are combined into one handler call, and the stored response is replayed to later requests. Use it only when that is correct. A token that maps to more than one user view — or that rotates — still needs a user-specific getKey.
When the response is per-user and must not be shared at all, bypass instead:
defineCachedHandler(myHandler, {
maxAge: 60,
shouldBypassCache: (event) => event.req.headers.has("authorization"),
});Non-cacheable methods such as POST, and requests excluded by shouldBypassCache, always reach the handler with credentials intact.
#Conditional requests & 304
The handler answers 304 Not Modified with an empty body when the request's if-none-match matches the stored etag. When there is no if-none-match, it answers 304 if if-modified-since is at or after the last-modified the handler set — ocache never creates that header, so a handler that sets no date is validated by its etag alone:
const handler = defineCachedHandler(() => new Response("<h1>Hi</h1>"), {
maxAge: 300,
});
const first = await handler({ req: new Request(url) });
const etag = first.headers.get("etag");
// Browser revalidates with the etag it received
const second = await handler({
req: new Request(url, { headers: { "if-none-match": etag } }),
});
second.status; // 304Matching details:
if-none-matchwins. When both headers are sent and the etag does not match, the full response is served andif-modified-sinceis ignored. Browsers send both together, so the date alone must never claim an outdated representation is current.- The header accepts a list of etags and matches any of them. Comparison is weak (
W/"abc"matches"abc"), and*always matches a cached response. - The
304echoes the headers that describe the representation it stands in for —cache-control,content-location,date,etag,expires,last-modified, andvary— so the client refreshes its freshness lifetime and downstream caches keep the variant dimension. The cache-status header is included too: aHITanswered as304is still aHIT.
ocache owns this decision alone. It captures the validators from the original request before narrowing removes them, so your handler never sees them. A handler that used to serve its own 304 — a serveStatic-style route, or one forwarding validators upstream — now always renders the full representation, and ocache decides the 304 from the stored etag. Put a validator in varies if the handler must read it (at the cost of one entry per distinct value); shouldBypassCache is usually the better choice.
#Headers-only mode
Set headersOnly: true to answer conditional requests without storing anything. The handler always runs; the request conditions are compared against the etag and last-modified of the response it just produced. On a match, a body-less 304 replaces that response; otherwise the response passes through unchanged.
const handler = defineCachedHandler(myHandler, {
headersOnly: true,
maxAge: 60,
});Use this when the handler already produces its own etag and last-modified. Without those headers no condition can match — ocache does not buffer the body to mint validators here. The mode saves the response transfer, not the handler run, and applies only to GET/HEAD responses with a cacheable status (200, 203, 301, 308). A custom handleCacheHeaders receives the same validators and can decide differently. The cache-status header is not emitted in this mode.
#Cache-status header
By default ocache adds a CDN-style X-Cache header reporting how the response was served: MISS, HIT, STALE, or REVALIDATED.
defineCachedHandler(myHandler, { maxAge: 60 }); // adds X-Cache
defineCachedHandler(myHandler, { maxAge: 60, cacheStatusHeader: "x-my-cache" }); // custom name
defineCachedHandler(myHandler, { maxAge: 60, cacheStatusHeader: false }); // disable#Response size
Storing a response means buffering its body first. A body that could never be stored is not worth buffering, so ocache stops reading one that grows past maxBodySize bytes:
const handler = defineCachedHandler(myHandler, {
maxAge: 60,
maxBodySize: 5 * 1024 * 1024, // never buffer more than 5 MB
});Without the option, the limit derives from the storage backend's declared per-entry ceiling, divided by the worst-case stored cost of a body byte. A backend that stores bytes natively — memory storage and anything else declaring binary — costs 2, the 2 bytes per character a text body charges. Everywhere else it is 8 / 3, because a binary body is base64-encoded first and that expands it by 4/3. Memory storage declares its maxBytes, so the largest body that fits one entry is maxBytes / 2 — 50 MB under the 100 MB default. A backend that declares no ceiling — a Redis or filesystem adapter, say — sets no limit: pass maxBodySize yourself there. Infinity or 0 buffers every response the backend accepts.
An oversized response is still served, in full. It streams straight through like a bypassed request: nothing stored, no X-Cache, no synthesized etag or Cache-Control. Every request for that resource runs the handler.
Note
Concurrent requests for one key normally share a single resolution, but a live stream can only be read once. The request whose handler produced the oversized body receives it; requests that had joined run the handler again for their own response.
Important
This limit exists because the process buffers the whole body before the storage ceiling can refuse the entry. Set it on any route that proxies upstream responses whose size you do not control.
#Streaming the first response
Filling an entry means buffering the handler's body, so the request that fills it normally waits for the last byte. For a token stream or a streaming render, that is the whole response. stream: true hands that one request the body as it is read, and stores the entry once the read completes:
const handler = defineCachedHandler(async () => new Response(await openTokenStream()), {
maxAge: 300,
stream: true,
maxResolveTime: 120,
});Everything else is unchanged. The stored entry is identical to a buffered one, so every later request is an ordinary HIT served from storage, and turning the option on does not invalidate entries written without it.
What the streamed response gives up is what needs the finished body:
- No synthesized
etag. The validator is a digest of the body, which does not exist yet. Anetagyour handler sets is kept, and every later request is served the stored entry, which has one. - No
304. A conditional request that arrives while the entry is filling receives the full body. - A mid-stream failure reaches the client as a truncated body. The status and headers are already sent, so there is no error response left to send. Nothing partial is ever stored — the next request runs the handler again.
Only the request actually waiting on the fill streams. Requests that join an in-flight resolution wait for the complete entry as they always have, and a stale request under SWR is served the stored value while its refresh fills behind it, never the refresh's stream.
Important
The resolution deadline bounds the whole read, not just the handler's first byte. A body that takes longer than maxResolveTime (default 30 seconds) to produce is aborted part-way, which reaches the client as a truncation. Raise it on a streaming route.
Note
A slow or disconnected client never slows or stops the fill: the entry is still being written for everyone else. Peak memory is unchanged — the served chunks and the buffered ones are the same chunks, bounded by maxBodySize. An over-limit body streams through in full and stores nothing.
#Resolution deadline
A handler that never responds cannot hold its cache key forever. After maxResolveTime seconds (default 30) the shared resolution is abandoned and every waiting request rejects with a TimeoutError. The functions guide covers the setting in full.
A handler additionally receives the deadline as an AbortSignal on event.req.signal. Pass it to the work you start, and a timed-out resolution releases its upstream connection instead of running on unwatched:
export default defineCachedHandler(
async (event) => {
const res = await fetch("https://slow.example/report", {
signal: event.req.signal, // aborted if this resolution hits its deadline
});
return new Response(await res.text());
},
{ maxAge: 300, maxResolveTime: 5 },
);The abort reason is the same TimeoutError the waiting requests receive, so fetch rejects with it.
Important
This is not the client's signal. Concurrent requests for one key share a single resolution, so a disconnecting client does not cancel the handler — other requests still wait for the same work, and a background SWR refresh has no client at all. Only the deadline aborts the signal; maxResolveTime: 0 or Infinity leaves a signal that never aborts.
Note
On serverless runtimes, background work — SWR refreshes, storage writes — is registered through the srvx-compatible event.req.waitUntil when the adapter provides it, so the platform keeps the instance alive until the refresh lands. Where the adapter does not, pass the platform hook as the waitUntil option; it takes precedence over the request's.
#Framework integration hooks
Three hooks adapt ocache to a framework's request/response types:
| Hook | Replaces |
|---|---|
toResponse(value, event) | Conversion of the handler return value to a Response. Default: pass a Response through; wrap a string, number, boolean, or body type (bytes, Blob, stream) in new Response(value); throw on anything else. |
createResponse(body, init) | Construction of the Response served from cache. body is a string (text), Uint8Array (binary), or null (empty / 304). Default: new Response(body, init). |
handleCacheHeaders(event, conditions) | The 304 decision. Return true to answer 304 immediately. Read the request validators from conditions.ifNoneMatch / conditions.ifModifiedSince, not event.req — narrowing has already removed them. |
const handler = defineCachedHandler(
// Handler returns a plain object...
async (event) => ({ id: 42, name: "Widget" }),
{
maxAge: 300,
// ...toResponse turns it into JSON
toResponse: (value) => Response.json(value),
},
);Without that hook, a handler returning an object fails the request with an
UnsupportedValueError instead of caching one. The default has no way to serialize an
object, and String(value) would store the body [object Object] under a valid etag
and replay it for the whole lifetime. Return a Response or set toResponse.
#On-demand revalidation
The returned handler carries .expire(event), .invalidate(event), and .resolveKeys(event), mirroring the methods on a cached function. Each takes a request event and derives the exact key the handler would use:
await page.expire(event); // mark stale — serve stale once more, refresh in background (SWR)
await page.invalidate(event); // remove — next request blocks on a fresh renderThese methods target the resource, not just the method in the event: they purge or expire both the GET and HEAD entries for that URL, so no entry survives that still advertises the old etag. .resolveKeys(event) returns the same set — one key per base prefix and method variant, with the event's key first.
Note
The standalone invalidateCache() / expireCache() helpers cannot see the request method and target only the variant their arguments imply. Prefer the handler methods for cached handlers.
See ISR → on-demand revalidation for the full recipe.
#More
- Query Parameters — control which params vary the cache.
- Cookies — the secure default and how to opt cookies back in.
- Cache-Control & Eligibility — response-side opt-outs,
must-revalidate,sendCacheControl,shouldCache. - Incremental Static Regeneration (ISR) — serve stale, revalidate in the background.