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

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.

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

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.

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

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

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:

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.

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

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:

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

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.

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.
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. Each takes the request event and derives the exact key the handler cached it under:

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 for the full recipe.

More