Repository navigation
Description
Design issue, not built. Decided on 2026-10-04: no separate router.warm(). router.prerender() stays the one verb for "make this URL ready before traffic", and dispatches by route. #640 ships it for Prerender(..., { onDemand }) routes; a PR stacked on #640, released in the same version, widens it to every route whose cache store is shared beyond the place the call runs.
Problem
After #640, router.prerender() renders a Prerender route with no request and writes it to the prerender store. Every other cache the router has fills only from a real visitor's request:
- a
pprroute's shell (captured on the first document MISS); - a route's
cache()entries and the document cache; "use cache"entries reached by the render;- a
clientUrls()group withppr(feat(client-urls): ppr as a projected group path option — shell caching for group routes #817). AclientUrls()group cannot usePrerender()at all (fix(client-urls): reject Static()/Prerender() handler values with a targeted message #818).
So after a deploy every key starts cold (per-app cache versions, #1034, retire the old entries of an app whose code changed), and the same after an updateTag(). The first visitor per key and partition pays the render.
API (shared with #640)
const prerender = router.prerender({ env, ctx });
await prerender("/products/1"); // any route
await prerender.many(urls, { concurrency: 4 }); // default concurrency 1
await prerender.markStale(["product:1"]); // the prerender store onlyDispatch
| Route | What prerender(url) does |
|---|---|
Prerender(..., { onDemand }) |
As in #640: a requestless render written to the prerender store. Its loaders' runtime caches (a loader with its own cache()) are warmed too, through the path below (open question 3). |
| Any other route, store shared | Warm: a cookie-free GET of the full URL through the normal handler, in a mode where every cache read is a miss and every write overwrites in place. It replaces the URL's ppr shell, its cache() entries, the "use cache" results the render reaches, and its document cache. The old entries serve until the new ones are written, so there is no cold window. A shared entry (a layout cache() used by many URLs) is refreshed for every URL that reads it. |
| Any other route, store not shared | Refused with its own status; warns once in dev. |
The per-target result says which path ran and what it filled or refused (PrerenderResult, extended).
Which stores are shared
Warming a cache that lives only in the colo, region or process where the call runs does nothing for traffic elsewhere.
| Store | Where entries live | Warm |
|---|---|---|
CFCacheStore with a KV binding |
L1 is the per-colo Cache API, L2 is KV (cf-cache-store.ts header: "L2 (KV): Global, persistent ... Auto-warms cold colos"; read flow "L1 miss → L2 hit → serve + promote to L1"). Cloudflare: "KV is a global, low-latency, key-value data store. It stores data in a small number of centralized data centers"; a write "may take up to 60 seconds or more to be visible in other global network locations". |
Yes |
CFCacheStore without KV (L1 only, #819) |
Per colo. | No |
MemorySegmentCacheStore |
Per process. | No |
VercelCacheStore (Runtime Cache) |
Vercel: "Runtime cache is a regional, ephemeral cache ... within a Vercel region"; "Each region has its own cache". Functions "execute in Washington, D.C., USA (iad1) for all new projects"; Hobby runs one region, Pro up to 5, Enterprise all. Entries can be evicted early (LRU at the storage limit). |
Yes, for single-region deployments, by documentation only (decided): no runtime region detection; the docs say a call fills the region it runs in, which is all traffic only when the functions run in one region. |
| A custom store | Unknown to the router. | Only if it declares itself shared. |
Mechanism: an optional declaration on SegmentCacheStore, for example scope: "global" | "regional" | "local", set by the shipped stores (CFCacheStore from whether it has KV; VercelCacheStore declares itself shared per the decision above; the memory store "local"). No new knob for app authors on the shipped stores. This is a change to the store contract custom stores implement and gets its own review.
Decided
- One verb,
router.prerender(), dispatched by route (not a separaterouter.warm()). - Bound runner,
.many()with per-callconcurrency(default 1); a large list belongs in a queue, one message per batch, because one invocation runs under the platform's time and CPU limits. - A warm replaces the URL's entries by forcing misses and overwriting in place; it needs no tags.
- Vercel: allowed for single-region deployments, documented, not detected.
- Built design-first in the stacked PR: a short write-up and red tests for the forced-miss mode, reviewed before the build (it reaches core paths every app runs through:
src/cache/cache-scope.ts,src/cache/cache-runtime.ts,src/rsc/shell-capture.ts, the document cache).
Open questions
- The synthetic request. A cookie-free
GETof the full URL (host and search params included, so it lands in the same partitions as a visitor;cache.searchParamsapplies). What headers, if any. A route that readscookies()or headers is already refused by the existing guards (a PPR capture refuses identity reads; acache()fill that reads cookies stores nothing): report those, do not work around them. - Which caches. The document shell and the route's
cache()/ document-cache entries. Navigation (partial) payloads depend on the page a visitor comes from and cannot be enumerated: leave them to the first navigation, or warm the navigation shell only. - A
Prerenderroute's loaders. Whether one call both refreshes the prerender store and warms the loaders' runtime caches, and in which order. - Result statuses.
skipped-not-on-demand(from feat(router): on-demand (ISR-style) prerender #640) becomes the warm path; new statuses for a refused non-shared store and per-cache outcomes. - Testing. A userland test through
serveShellRequest(anddispatchwhere it applies), as the repo requires for consumer-touchable features.
Possible follow-up, not part of this: invalidating a URL without rendering it
Like Next.js revalidatePath. If built, follow Next.js's read-side design rather than stamping path tags on entries: Next.js derives implicit tags from the path at read time and compares them with the entry's timestamp ("the implicit tags' expirations are stored in the work unit store, and used to compare with a cache entry's timestamp", packages/next/src/server/lib/implicit-tags.ts). Stamping tags on entries would miss shared entries (a layout entry written while rendering /a never gets /b's tag), add a KV marker read per tag on every hit, and count against Vercel's 128-tag limit.
Prior art
Next.js App Router regenerates only on a request: "A revalidation is triggered by a request, not by the revalidateTag call", and "We're working on adding new methods to provide eager regeneration capabilities for the App Router" (nextjs.org/docs/app/guides/incremental-static-regeneration, quoted in the #640 API review).
Related: #640 (on-demand prerender), #1060 (its follow-ups, including a Vercel prerender store), #1034 (per-app cache versions), #1043.
Activity
ivogt commentedon Oct 4, 2026
Decision on open question 1 (Vercel), from the maintainer: allow
router.warm()withVercelCacheStore, for single-region deployments, and handle it in documentation rather than code. No runtime detection of the region count. The docs state that the Runtime Cache is per region, that a warm fills the region it runs in, and that this covers all traffic only when the project's functions run in one region (the defaultiad1for new projects, and every Hobby project); on a multi-region project it warms one region.VercelCacheStoretherefore declares itself shared for warm.ivogt commentedon Oct 4, 2026
Two more design decisions from the maintainer (2026-10-04).
Shape: a runner bound to
envandctx, likerouter.prerender()after #640.many()keeps a per-callconcurrency(default 1, so a refresh never fans out against the data source unless asked); a large list belongs in a queue, one message per batch, since one invocation runs under the platform's time and CPU limits.A warm replaces the URL's entries; it needs no tags. The warm's request treats every cache read as a miss and overwrites what the render writes, under the same keys: the
pprshell, the route'scache()entries,"use cache"results the render reaches, the document cache. The old entry serves until the new one is written, so there is no cold window (invalidate-then-fill would have one). A shared entry (a layoutcache()used by many URLs) is refreshed for every URL that reads it.Not part of warm, possible follow-up: invalidating a URL without rendering it (Next.js
revalidatePath). If built, follow Next.js's read-side design rather than stamping path tags on entries. Next.js derives implicit tags from the path at read time and compares them with the entry's timestamp ("the implicit tags' expirations are stored in the work unit store, and used to compare with a cache entry's timestamp",packages/next/src/server/lib/implicit-tags.ts). Stamping tags on entries would miss shared entries (a layout entry written while rendering/anever gets/b's tag), would add a KV marker read per tag on every hit, and would count against Vercel's 128-tag limit.[-]router.warm(): fill a route's runtime caches before traffic, only for shared stores (design)[/-][+]router.prerender() for every route: warm runtime caches before traffic, shared stores only (design)[/+]ivogt commentedon Oct 4, 2026
Body rewritten to the decided design: one verb,
router.prerender(), dispatched by route; no separaterouter.warm(). The earlier comments are kept for history; the body is current.feat(router): router.prerender() warms any route before traffic (#1063)
feat(router): remove a page from on-demand prerender (#1060) (#1070)