Caching & ISR

Origin-first incremental static regeneration: stale-while-revalidate, on-demand purge by path or tag, scheduled poll regeneration, and getStaticPaths fallbacks — on any host, no CDN required.

The model

What's caching is origin-first. A render cache lives next to your server and does the full job: it serves fresh hits instantly, serves stale content while regenerating in the background, dedupes concurrent regenerations, and purges on demand. A CDN is optional upside — when present, the engine emits the right Cache-Control headers and fans purges out to it. Nothing about your code changes whether or not you have one.

import { createCacheEngine, createMemoryStore } from 'what-isr';

const cache = createCacheEngine({ store: createMemoryStore() });

Swap the store without touching pages: createMemoryStore() (default, fast, single-process), createFilesystemStore({ dir }) (survives restarts, multi-process), or createRedisStore({ client }) (multi-instance).

Per-page config

A page declares its caching policy with the JSON-safe page export:

export const page = {
  mode: 'static',     // 'static' | 'hybrid' | 'server'
  revalidate: 60,     // seconds until a cached entry goes stale
  swr: 600,           // extra seconds it may be served stale while regenerating
  tags: ['posts'],    // purge handles for revalidateTag
  vary: ['cookie:theme'],   // split cache by these request signals
  fallback: 'blocking',     // for dynamic routes (see Data Loading)
  pollInterval: 300,        // background regeneration, seconds (optional)
};
  • mode: 'static' — cacheable, regenerated by ISR.
  • mode: 'server' — always rendered fresh, never cached (private, no-store). Use for per-user pages.
  • mode: 'hybrid' — static shell with dynamic islands.

Stale-while-revalidate

After revalidate seconds an entry is stale but still served instantly; the engine kicks off one background re-render and swaps the entry in when it's done. Readers never wait. If swr is set, stale-if-error keeps serving the last good copy when regeneration fails.

One render for N concurrent misses

When a thousand requests hit a stale entry at once, an in-flight lock (a per-key promise, or a Redis SET NX across instances) ensures exactly one regeneration runs — the rest are served the stale copy. No thundering herd.

On-demand revalidation

Purge precisely when your data changes — from an action, a route, or anywhere on the server:

import { revalidatePath, revalidateTag } from 'what-framework/server';

revalidatePath('/blog/hello');   // one path
revalidateTag('posts');          // every entry tagged 'posts'

This is progressive regeneration: the purged entry re-renders on its next request (or immediately, with { regenerate: true }). Pages stay cached until the moment their data actually changes.

Poll regeneration

For data that drifts without an explicit trigger (an external feed, prices), set pollInterval or register a route with the scheduler. It re-renders on a timer with jitter (anti-herd), a global concurrency cap, and it joins the same in-flight lock so a tick during a live regeneration is a no-op.

import { createScheduler } from 'what-isr';

const scheduler = createScheduler(cache);
scheduler.register(
  { path: '/', query: {}, config: routes[0].page },
  { intervalMs: 5 * 60 * 1000 }   // keep the home page warm every 5 min
);
// scheduler.start() / stop() — the Node adapter wires SIGTERM cleanup.

Revalidation webhook

Let a CMS trigger purges over HTTP. Mount createRevalidateWebhook (the adapters expose it at POST /__what_revalidate) with a secret:

import { createRevalidateWebhook } from 'what-isr';
const webhook = createRevalidateWebhook(cache, { secret: process.env.WHAT_REVALIDATE_SECRET });

// POST /__what_revalidate
// { "tags": ["posts"], "paths": ["/"], "secret": "…", "regenerate": true }

The secret is checked in constant time.

Cache headers

When a CDN is in front, the engine emits standard headers so the edge caches and revalidates in lockstep with the origin:

Cache-Control: public, s-maxage=60, stale-while-revalidate=600
Cache-Tag: posts            // Fastly / generic
Surrogate-Key: posts        // (alias)
X-What-Cache: HIT | STALE | MISS

Server-mode and action responses send private, no-store. Skeleton (fallback true) responses send s-maxage=0 so the edge doesn't pin a placeholder.

No-CDN vs CDN — graceful degradation

Every capability works at the origin. A CDN only changes where the cache also lives.

CapabilityOrigin only (no CDN)With a CDN
Fresh / stale serving (SWR)✓ origin store✓ origin + edge
In-flight dedupe✓ per-process / Redis✓ same
revalidatePath / revalidateTag✓ purges origin store✓ purges origin and edge (CDNAdapter.purge)
Poll regeneration✓ scheduler in the server process✓ same (origin re-render → edge revalidate)
getStaticPaths fallback✓ render-on-first-hit✓ same, then edge-cached
Edge latencyorigin round-trip✓ served from nearest PoP

Provide a CDN with createCacheEngine({ store, cdn }) — adapters ship for cloudflare, fastly, and vercel. Omit it and every line above still holds, minus edge latency. That is the whole promise: no host lock-in.