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

```ts
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`](/docs/storage#memory-storage--lru) 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](/docs/cache-control#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](/docs/cache-control).

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

```ts
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](/docs/invalidation) 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`](/docs/invalidation) 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`](/docs/functions#dynamic-ttl) — 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):

```ts
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](/docs/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`](/docs/cache-control#suppress-synthesized-freshness-sendcachecontrol) stops advertising the lifetime — though it does not emit `no-store` or guarantee downstream caches refuse storage.
