
# 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.

```ts
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](/docs/functions#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](#cache-key-name), the request [origin](#multi-host-deployments), the URL path, and the method — plus the [query parameters](/docs/query-params) and [headers](#varying-by-request-headers) you opt in to. Neither the query string nor any header varies the key until you declare it.
- **Conditional requests** — a `304 Not Modified` when the client sends a matching `if-none-match` or `if-modified-since`.
- **Response headers** — `etag` and `cache-control` are synthesized when the handler does not set them. `last-modified` never 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 in `varies`, plus `Cookie` under [`allowCookies`](/docs/cookies). A response whose own `Vary` names an undeclared header is [refused storage](/docs/cache-control#what-is-never-cached).
- **Binary responses** — images, tiles, and other non-text bodies are cached and replayed byte for byte.
- **Streaming fills** — opt in with `stream` and the request that fills an entry receives the body as it is produced. See [Streaming the first response](#streaming-the-first-response).
- **A resolution deadline** — a handler or upstream that never responds cannot hold its key forever. See [Resolution deadline](#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.

```ts
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 intact
```

> [!IMPORTANT]
> 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:

```ts
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`](#credential-headers): 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](/docs/functions#cache-keys): `opts.name || handler.name || anon_<hash(handler)>`. The name is what keeps two handlers on one shared `storage` from colliding on the same path.

```ts
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.
>
> ```ts
> // ✗ 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`](/docs/query-params#edge-cases) 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:

```ts
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`](/docs/cookies) likewise adds `Cookie` to `Vary` — the key uses only the allowed cookie subset, but `Vary` cannot express a subset. [`allowQuery`](/docs/query-params) 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:
>
> ```ts
> 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](/docs/cookies) and [Credential headers](#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.

```ts
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 in `Vary`. One entry per distinct value.
- **[`shouldBypassCache`](#cacheable-requests)** — keep those requests out of the cache entirely. A bypassed request reaches the handler unchanged.

The [query string](/docs/query-params) 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](#multi-host-deployments), 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 `304`s itself — see [Conditional requests & 304](#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](/docs/query-params) 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`](/docs/cookies) — 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`:

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

```ts
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; // 304
```

Matching details:

- **`if-none-match` wins.** When both headers are sent and the etag does not match, the full response is served and `if-modified-since` is 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 `304` echoes the headers that describe the representation it stands in for — `cache-control`, `content-location`, `date`, `etag`, `expires`, `last-modified`, and `vary` — so the client refreshes its freshness lifetime and downstream caches keep the variant dimension. The [cache-status header](#cache-status-header) is included too: a `HIT` answered as `304` is still a `HIT`.

ocache owns this decision alone. It captures the validators from the original request before [narrowing](#headers-the-handler-cant-see) 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.

```ts
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](#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`.

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

```ts
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](/docs/storage#memory-storage--lru) and anything else declaring [`binary`](/docs/storage#the-storage-interface) — 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](#cacheable-requests): 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:

```ts
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. An `etag` your 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](/docs/isr) is served the stored value while its refresh fills behind it, never the refresh's stream.

> [!IMPORTANT]
> The [resolution deadline](#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`](#response-size). 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](/docs/functions#resolution-deadline) 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:

```ts
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](/docs/functions#background-work); 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.     |

```ts
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](/docs/invalidation). Each takes a request `event` and derives the exact key the handler would use:

```ts
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 render
```

These 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](/docs/isr#on-demand-revalidation) for the full recipe.

## More

- [Query Parameters](/docs/query-params) — control which params vary the cache.
- [Cookies](/docs/cookies) — the secure default and how to opt cookies back in.
- [Cache-Control & Eligibility](/docs/cache-control) — response-side opt-outs, `must-revalidate`, `sendCacheControl`, `shouldCache`.
- [Incremental Static Regeneration (ISR)](/docs/isr) — serve stale, revalidate in the background.
