Incremental Static Regeneration

You can reproduce ISR-style behavior with defineCachedHandler: serve a cached page instantly, regenerate it in the background once it goes stale, and keep serving the last good version until the refresh lands.

const page = defineCachedHandler(
  async (event) => {
    const url = event.url ?? new URL(event.req.url);
    const html = await renderPage(url);
    return new Response(html, { headers: { "content-type": "text/html" } });
  },
  {
    swr: true, // serve stale instantly, refresh in the background
    maxAge: 60, // "revalidate" window: fresh for 60s, then refresh on next request
    // no staleMaxAge → the last good page is served until a refresh replaces it
  },
);

Two options make it ISR-like:

  • swr: true turns on stale-while-revalidate: once an entry is older than maxAge, the next request gets the stale page immediately while a fresh render runs in the background.
  • Omit staleMaxAge. This is the important part. Leaving it unset means there's no point at which an entry becomes "too old to serve" — the last successful render is served indefinitely until a refresh replaces it, exactly like ISR. If you instead set staleMaxAge, you get a hard cutoff: after maxAge + staleMaxAge the entry is dropped and the next request blocks on a fresh render.

With this config the handler also emits Cache-Control: s-maxage=60, stale-while-revalidate, so any shared/CDN cache in front of it revalidates on the same schedule. (Set staleMaxAge and the directive becomes s-maxage=60, stale-while-revalidate=<staleMaxAge>.)

If your handler sets its own Cache-Control, ocache leaves it untouched and won't synthesize one. See Cache-Control.

On-demand revalidation

The handler returned by defineCachedHandler exposes .expire(), .invalidate(), and .resolveKeys() — the equivalent of Next's revalidatePath / revalidateTag. Each takes the request event and derives the exact key the handler cached it under, so there's no key to reconstruct:

const page = defineCachedHandler(
  async (event) => {
    const url = event.url ?? new URL(event.req.url);
    return new Response(await renderPage(url), {
      headers: { "content-type": "text/html" },
    });
  },
  { swr: true, maxAge: 60 },
);

// ISR-style: serve the stale page once more, refresh in the background
await page.expire(event);

// Hard purge: next request blocks on a fresh render
await page.invalidate(event);

Prefer .expire() for the ISR feel — there's no blocking gap for visitors. Reach for .invalidate() only when the next reader must get a guaranteed-fresh render. See Invalidation & Expiration for the underlying semantics.

These methods key off the event exactly as the handler does — including any varies headers and allowQuery / allowCookies values on the request. Pass an event that matches the variant you want to revalidate. For revalidation from somewhere without an event (a webhook, a cron job), the standalone expireCache / invalidateCache helpers rebuild the key from options instead.

Per-route revalidate windows

If different pages need different refresh intervals (like Next's per-fetch revalidate), use getMaxAge to derive the window from the response — for example an x-revalidate header your handler sets. Inside getMaxAge, entry.value is the standard Response:

const page = defineCachedHandler(
  async (event) => {
    const url = event.url ?? new URL(event.req.url);
    const { html, revalidate } = await renderPage(url);
    return new Response(html, {
      headers: { "content-type": "text/html", "x-revalidate": String(revalidate) },
    });
  },
  {
    swr: true,
    getMaxAge: (entry) => {
      const revalidate = entry.value.headers.get("x-revalidate");
      return revalidate ? Number(revalidate) : 60; // missing header → default window
    },
  },
);

Differences from CDN-managed ISR

Three things differ from CDN-managed ISR.
  1. Background refresh is coalesced per instance, not globally — across multiple servers / serverless instances the origin can see one refresh per instance. Add a distributed lock in your custom storage if regeneration is expensive.
  2. Entries never auto-expire with staleMaxAge omitted, so storage grows until you purge with invalidateCache — or set a large staleMaxAge to trade exact ISR semantics for eventual cleanup.
  3. A browser/CDN can still serve stale after a purgeinvalidateCache/expireCache only clear server-side storage; a client or CDN that already cached the synthesized Cache-Control response keeps serving it, unaware of the purge. Set sendCacheControl: false to keep caching decisions entirely server-side.