Incremental Static Regeneration

ISR-style behavior — 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 — is two options on defineCachedHandler:

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
  },
);
  • swr: true — when an entry is older than maxAge, the next request gets the stale page immediately and a fresh render runs in the background.
  • Omit staleMaxAge — this is the important part. Without it, an entry never becomes too old to serve: the last successful render is served until a refresh replaces it, which is exactly ISR. Setting staleMaxAge bounds the stale window instead — after maxAge + staleMaxAge the entry is removed (that sum is its storage TTL) and the next request waits for a fresh render.

Note

With staleMaxAge omitted, entries have no storage TTL and remain until the backend evicts them — capacity, not a timer, bounds cache growth. The built-in createMemoryStorage evicts LRU entries above 10 000 entries or 100 MB (maxSize / maxBytes); size those limits for the pages you serve. An external backend needs its own capacity limit. Setting staleMaxAge enables eventual cleanup, at the cost of exact ISR semantics.

#What downstream caches see

This config emits Cache-Control: max-age=60, s-maxage=60, so caches in front revalidate on the same schedule. It deliberately does not advertise stale-while-revalidate: the directive requires a number of seconds (RFC 5861 §3), and an unlimited stale window has none. A downstream cache simply revalidates after 60 seconds; ocache answers instantly from its retained copy and regenerates in the background — ISR runs where ocache enforces it. With staleMaxAge set, the window has a number and max-age=60, s-maxage=60, stale-while-revalidate=<staleMaxAge> is advertised. See what is advertised.

Note

If your handler sets Cache-Control, ocache neither changes nor synthesizes it — and it reads it: an opt-out (no-store, private, no-cache, max-age=0) keeps the page out of the cache, and must-revalidate keeps it cached but forbids stale serving, disabling ISR for that page. See Cache-Control & Eligibility.

#On-demand revalidation

The handler's .expire(), .invalidate(), and .resolveKeys() methods play the role of Next's revalidatePath / revalidateTag. Each takes the request event and derives the exact cache key from it — no key reconstruction:

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

Use .expire() to refresh without blocking visitors; reserve .invalidate() for when the next reader must get a guaranteed fresh render. See Invalidation & Expiration for the detailed semantics.

Tip

These methods interpret the event like the handler does, including the request values behind varies, allowQuery, and allowCookies — pass an event that matches the variant you want to revalidate. The request method is the exception: GET and HEAD key separately, but revalidating either purges both, so a page can never keep a stale HEAD entry. To revalidate without an event — from a webhook or cron job — use the standalone expireCache / invalidateCache helpers, which rebuild the key from options.

#Per-route revalidate windows

When pages need different refresh intervals, derive the window from the response with getMaxAge — the analogue of Next's per-fetch revalidate. In getMaxAge, entry.value is the standard Response; a convenient pattern is a custom header set by the handler. The hook defines the lifetime, so no static maxAge is needed — entries the hook gives no lifetime fall back to the static option (the 1-second default unless set):

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

The advertised header follows the hook too: the page sends Cache-Control: max-age=<revalidate>, s-maxage=<revalidate> rather than the static option. (Before v1 the synthesized header always used the static maxAge, so ocache regenerated on the hook's schedule while upstream caches held the page for the static lifetime.)

#Differences from CDN-managed ISR

Note

Three behaviors differ from platform-managed ISR:

  1. Duplicate background refreshes are combined per instance, not globally. With multiple servers or serverless instances, the origin can receive one refresh from each. If regeneration is expensive, add a distributed lock in your custom storage.
  2. Entries never expire on their own when staleMaxAge is omitted. That keeps the last good render available — and lets storage grow until the backend evicts (memory storage: 10 000 entries or 100 MB, whichever comes first) or you purge with invalidateCache. Set staleMaxAge for eventual cleanup, at the cost of exact ISR semantics.
  3. A browser or CDN can serve stale content after a purge. invalidateCache / expireCache clear only server-side storage; a downstream cache that stored the response under the synthesized Cache-Control knows nothing of the purge. sendCacheControl: false stops advertising the lifetime — though it does not emit no-store or guarantee downstream caches refuse storage.