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 Cache-Control headers a standards-compliant edge can act on, and fans purges out to it. Nothing about your code changes whether or not you have one.
This page is the server render cache: it caches whole rendered pages, it lives in the what-isr package, and its vocabulary is revalidatePath / revalidateTag. The in-browser query cache that useSWR and invalidateQueries talk to is a different system, documented in Caching.
import { createCacheEngine, createMemoryStore } from 'what-isr';
import { createRequestHandler, renderDocument } from 'what-framework/server';
import { routes } from './src/routes.js';
const documentOptions = { clientEntry: '/src/entry-client.js' };
// `render(routeMatch, ctx)` is injected, never imported: the engine has no
// dependency on what-server. It resolves to { html, head, state, status, tags }.
async function renderRoute(routeMatch) {
const { route, params, query, request } = routeMatch;
const reqCtx = { params, query, request };
const pageModule = { default: route.component, loader: route.loader };
const html = await renderDocument(pageModule, reqCtx, documentOptions);
return { html, status: 200, tags: routeMatch.config?.tags || [], path: routeMatch.path };
}
const cache = createCacheEngine({ store: createMemoryStore(), render: renderRoute });
const handler = createRequestHandler({ routes, cache, document: documentOptions });
Leaving render out is easy, and it half-works, which is worse. The adapter supplies its own render for each incoming request, so ordinary traffic looks fine; every regeneration the adapter does not drive (the scheduler, a { regenerate: true } purge) calls the engine's own render and raises TypeError: doRender is not a function. Passing the engine as cache also binds it for revalidatePath / revalidateTag, covered below.
That TypeError does not reach you. Both of those call sites catch it, so the failure is a log line at most and the call still looks like it worked:
- The scheduler logs
[what-isr] scheduled regenerate failed: TypeError: doRender is not a functionand reschedules, so it repeats on every tick for the life of the process. revalidatePath(path, { regenerate: true })logs[what-isr] regen after revalidatePath failed: ...and still returns the keys it purged, so the purge reports success and nothing re-renders.revalidateTag(tag, { regenerate: true, routeResolver })swallows it with no message at all, deliberately: a purge must not fail because a regeneration did.
Only cache.regenerate(routeMatch), called directly, throws to its caller. Watch the log rather than waiting for a crash.
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 static page export, a plain object literal of JSON-shaped values:
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
fallback: 'blocking', // for dynamic routes (see Data Loading)
onMiss: 'stale-if-error' // keep serving the last good copy if a re-render throws
};
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.revalidateomitted on astaticroute means durable until an explicit purge; omitted onhybridit defaults to 60 seconds. An explicitrevalidate: 0means "always revalidate".swromitted defaults torevalidate, not to zero.vary: ['cookie:theme']splits the origin cache by those request signals (a bare string is shorthand for a one-element list). A route that declaresvaryis never markedpublic: it answersCache-Control: private, no-store, so no shared cache or CDN will hold it. If the adapter supplies no request headers to resolve the declaration against, the route is served uncached rather than sharing one entry between users.
The build reads this statically
The literal is tokenized at build time, so every value has to be one. Strings, numbers, booleans, null, arrays and nested objects all work, and so do comments, trailing commas, and apostrophes, colons or // inside a string value. What the build cannot do is evaluate, so revalidate: 60 * 60, a template interpolation or a reference to an imported constant is a parse failure. A page whose config cannot be read falls back to mode: 'client' in the generated routes table, which drops it out of static generation, and the build names the file and prints the parser's own complaint. export const page = { mode: 'static', revalidate: 60 * 60 }; in src/pages/blog.js gets you:
[what] Ignoring unparseable `export const page` config in src/pages/blog.js: Expected ',' or '}' after property value in JSON at position 37 (line 1 column 38)
This page falls back to mode 'client', so static generation will skip it.
`page` must be a plain object literal of JSON-shaped values (strings, numbers, booleans, null, arrays, nested objects).
Released 0.13.4 reads this config with a regex
Everything above describes the tokenizer, which lands in the next release. On 0.13.4, the current version on npm, the build matches the literal with a chain of regexes that cannot see string boundaries, so a value carrying a parser-significant character corrupts the whole export: vary: ['cookie:theme'], canonical: 'https://x.com/a', title: "What's new" and any nested object each make the entire page declaration unreadable. There is no diagnostic on 0.13.4 either, in any of those cases or for a real parse failure like revalidate: 60 * 60. The page silently becomes mode: 'client' and drops out of static generation.
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. swr is how many extra seconds past expiry that stale copy may still be served while regenerating; it defaults to revalidate when omitted. Past that window the request blocks on a fresh render.
Serving the last good copy when a regeneration throws is a separate switch: set onMiss: 'stale-if-error' in the page config. Without it, a failed regeneration past the swr window propagates and the reader gets an error page.
One render for N concurrent misses
When a thousand requests hit a stale entry at once, a per-key promise inside the engine ensures exactly one regeneration runs and the rest are served the stale copy. That lock is per process: behind a load balancer, each instance regenerates the key once. There is no cross-instance lock, not even with the Redis store.
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 }, which calls the engine's own render. Regenerating after a tag purge also needs a routeResolver (revalidateTag('posts', { regenerate: true, routeResolver })), because a tag on its own does not say which routes to re-render. Pages stay cached until the moment their data actually changes.
Both are inert until an engine is bound
These two functions are a thin indirection over whichever cache engine is registered, so that app code can import them from what-framework/server without depending on what-isr. createRequestHandler({ routes, cache }) registers the engine for you. With nothing registered (a hand-rolled server), both are no-ops: they return undefined, log a development-mode warning, and purge nothing. Bind one yourself with setRevalidationHandler(cache), also exported from what-framework/server.
Poll regeneration
For data that drifts without an explicit trigger (an external feed, prices), register the route with the scheduler. It re-renders on a timer with jitter (anti-herd), a global concurrency cap of 4, and it joins the same in-flight lock so a tick during a live regeneration is a no-op.
import { createScheduler } from 'what-isr';
import { createServer } from 'what-framework/server';
import { routes } from './src/routes.js';
// A registered route is a full routeMatch, the same shape the adapter builds
// for a live request. `render` gets exactly this object, so `route` and
// `params` have to be here or the re-render has nothing to render.
const scheduler = createScheduler(cache);
scheduler.register(
{ path: '/', query: {}, params: {}, config: routes[0].page, route: routes[0] },
{ intervalMs: 5 * 60 * 1000 } // keep the home page warm every 5 min
);
// register() only queues the task. Hand the scheduler to createServer and the
// Node adapter starts it and stops it on SIGTERM/SIGINT.
createServer({ routes, cache, scheduler }).listen(3000);
On any other host, call scheduler.start() yourself and scheduler.stop() on shutdown. Do not do both: register() alone schedules nothing, and starting twice leaves two timer chains running per task. The scheduler calls the engine's own render, so an engine built without one fails on the first tick and on every tick after it: the task is rescheduled either way, and each failure is a logger.error line rather than an exception anyone can catch.
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';
import { createRequestHandler } from 'what-framework/server';
import { routes } from './src/routes.js';
// `regenerate` is operator policy and lives here, not in the request body: a
// webhook must not be able to force blocking origin re-renders.
const webhook = createRevalidateWebhook(cache, {
secret: process.env.WHAT_REVALIDATE_SECRET,
regenerate: false,
});
// The path only exists once the handler has the webhook. Leave this option
// out and the POST falls through to route matching and 404s.
const handler = createRequestHandler({ routes, cache, revalidateWebhook: webhook });
// POST /__what_revalidate
// x-what-revalidate-secret: <secret>
// { "tags": ["posts"], "paths": ["/"] }
The secret travels in the x-what-revalidate-secret header, never in the body, and is compared in constant time over fixed-width digests so neither its contents nor its length leak through response timing. A request without a matching header gets a 401 before anything is purged. One request may carry at most 100 paths and tags combined.
Cache headers
When a CDN is in front, the engine attaches standard headers to every response it serves and the request handler passes them through untouched. A route declaring revalidate: 60, swr: 600, tags: ['posts'] answers:
Cache-Control: public, s-maxage=60, stale-while-revalidate=600
Cache-Tag: posts // Cloudflare / Fastly (comma-separated)
Surrogate-Key: posts // Fastly surrogate keys (space-separated)
X-What-Cache: HIT | STALE | MISS
Server-mode responses send private, no-store, and so does any route that declares vary, any non-200 render, and any render marked private. A route declaring vary also gets a Vary header naming the request headers a shared cache would have to key on, so vary: ['cookie:theme'] answers Vary: Cookie next to its no-store. Skeleton (fallback true) responses send s-maxage=0, so no edge can serve one as fresh. The swr window is not zeroed alongside it (a route with swr: 600 answers public, s-maxage=0, stale-while-revalidate=600), so a CDN honouring stale-while-revalidate can still hand the placeholder out while it revalidates.
X-What-Cache: BYPASS is an engine-level status for a render the engine refused to cache, and the shipped request handler does not produce it: mode: 'server' never reaches the engine at all, and answers private, no-store with no X-What-Cache header. You will see it if you call cache.handle() yourself without supplying request headers for a route that declares vary.
The edge is not tracking origin freshness
These headers let a standards-compliant CDN run its own SWR cycle. They do not keep it in step with the origin store, and two cases are worth knowing before you put a CDN in front.
s-maxage comes from the route's declared revalidate, so a static route that declares none answers public, s-maxage=0, stale-while-revalidate=0. That entry is durable at the origin until an explicit purge, and the header tells a shared cache to revalidate on every request. It is also byte-identical to what revalidate: 0 emits, which means the opposite at the origin. Declare a revalidate on anything you want the edge to hold.
A STALE response carries the same full s-maxage as a fresh one, so the edge starts a new window from a copy the origin has already expired. The two caches run separate clocks; a purge is what brings them back together.
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, once revalidate is declared (see above) |
| In-flight dedupe | ✓ per-process (not cross-instance) | ✓ same |
| revalidatePath / revalidateTag | ✓ purges origin store | ✓ purges origin and edge (CDNAdapter.purge / purgeTags). The Vercel adapter purges by tag only: its path purge is a no-op |
| Poll regeneration | ✓ scheduler in the server process | ✓ same at the origin. The re-render does not purge the edge, which refreshes when its own s-maxage lapses |
| 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, render, cdn }). Three adapters ship from what-isr: createCloudflareCDN({ zoneId, apiToken }), createFastlyCDN({ serviceId, apiToken }), and createVercelCDN({ token, projectId }). Omit cdn and every line above still holds, minus edge latency. That is the whole promise: no host lock-in.