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

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

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

<note>

If your handler sets its own `Cache-Control`, ocache leaves it untouched and won't synthesize one. See [Cache-Control](/guide/cache-control).

</note>

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

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

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](/guide/invalidation) for the underlying semantics.

<tip>

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`](/guide/invalidation) helpers rebuild the key from options instead.

</tip>

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

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

## Differences from CDN-managed ISR

<note>

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](/guide/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 purge** — `invalidateCache`/`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`](/guide/cache-control#server-only-caching-sendcachecontrol) to keep caching decisions entirely server-side.

</note>
