# Caching HTTP Handlers

> 

Wrap an HTTP handler with `defineCachedHandler` to cache its `Response` automatically, complete with `etag`, `last-modified`, and `304 Not Modified` support. It works with any runtime that has standard `Request`/`Response` — the event is just `{ req: Request; url?: URL }`.

```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, // cache 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; subsequent requests are served from the cache until the entry expires.

<note>

`maxAge` defaults to `1` second. Set it explicitly for anything you actually want cached.

</note>

## What it does

- **Cache keys** — auto-generated from the request URL path (plus any query params and headers you opt into varying, see below).
- **Conditional requests** — answers `304 Not Modified` when the client sends a matching `if-none-match` / `if-modified-since`.
- **Response headers** — synthesizes `etag`, `last-modified`, and `cache-control` when the handler didn't set them.
- **Vary** — advertises which request headers the response varies on (from `varies`).
- **Binary responses** — images, tiles, and other non-text bodies are cached and replayed byte-for-byte.

## Cacheable requests

Only `GET` and `HEAD` requests are cached. Every other method (`POST`, `PUT`, `DELETE`, ...) bypasses the cache and reaches the handler 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 — no body buffering (streaming bodies survive), no synthesized cache headers, and no spurious `304`.

</important>

You can bypass additional requests with `shouldBypassCache`, which is ANDed on top of the built-in method check (it can only bypass *more* requests, never force-cache a non-`GET`/`HEAD` one):

```ts
const handler = defineCachedHandler(myHandler, {
  maxAge: 60,
  // Skip the cache for logged-in users
  shouldBypassCache: (event) => event.req.headers.has("authorization"),
});
```

## Varying by request headers

By default the cache key depends only on the URL path — every client shares one cached entry. List header names in `varies` to cache a separate 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
  },
);
```

The same header names are also merged into the response `Vary` header (case-insensitively deduped, and a handler-set `Vary: *` is left untouched) so downstream caches and CDNs store per-variant too.

<tip>

**Only headers in varies reach the handler.** Every other request header is stripped before the handler runs, so a handler can never accidentally produce output that depends on a header outside the cache key. (Cookies are handled separately — see [Cookies](/guide/cookies).)

</tip>

## Conditional requests & 304

When a client re-requests with `if-none-match` (matching the stored `etag`) or `if-modified-since` (at or after `last-modified`), the handler returns `304 Not Modified` with an empty body:

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

The `304` echoes the `Vary` header so downstream caches keep the variant dimension.

## Headers-only mode

Set `headersOnly: true` to answer conditional requests **without** caching the response body. On a conditional match the handler short-circuits with a `304`; otherwise your handler runs and its response is returned as-is (never stored):

```ts
const handler = defineCachedHandler(myHandler, {
  headersOnly: true,
  maxAge: 60,
});
```

Use this when the handler already produces its own `etag`/`last-modified` and you only want the `304` shortcut, not full response storage.

## Cache-status header

By default a CDN-style `X-Cache` header is added, 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
```

## Framework integration hooks

To adapt to a specific framework's request/response types, override any of these:

- `toResponse(value, event)` — convert the handler's return value into a `Response`. Default: return it as-is if it's already a `Response`, otherwise `new Response(String(value))`.
- `createResponse(body, init)` — build the final `Response` served from the cache. `body` is a `string` (text), a `Uint8Array` (binary), or `null` (empty / `304`). Default: `new Response(body, init)`.
- `handleCacheHeaders(event, conditions)` — decide whether to answer with a `304`. Return `true` to short-circuit. Default: the built-in `if-none-match` / `if-modified-since` check.

```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),
  },
);
```

## On-demand revalidation

The returned handler carries `.expire(event)`, `.invalidate(event)`, and `.resolveKeys(event)`, mirroring the methods on a [cached function](/guide/invalidation). Each takes the request `event` and derives the exact key the handler cached it under:

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

See [ISR → on-demand revalidation](/guide/isr#on-demand-revalidation) for the full recipe.

## More

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