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.
| Capability | Origin 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 latency | origin 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.