Skip to content

router.prerender() for every route: warm runtime caches before traffic, shared stores only (design) #1062

Closed

Description

@ivogt

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:

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 only

Dispatch

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 separate router.warm()).
  • Bound runner, .many() with per-call concurrency (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

  1. The synthetic request. A cookie-free GET of the full URL (host and search params included, so it lands in the same partitions as a visitor; cache.searchParams applies). What headers, if any. A route that reads cookies() or headers is already refused by the existing guards (a PPR capture refuses identity reads; a cache() fill that reads cookies stores nothing): report those, do not work around them.
  2. 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.
  3. A Prerender route's loaders. Whether one call both refreshes the prerender store and warms the loaders' runtime caches, and in which order.
  4. 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.
  5. Testing. A userland test through serveShellRequest (and dispatch where 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

  1. ivogt commented on Oct 4, 2026

    @ivogt
    CollaboratorAuthor

    Decision on open question 1 (Vercel), from the maintainer: allow router.warm() with VercelCacheStore, 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 default iad1 for new projects, and every Hobby project); on a multi-region project it warms one region. VercelCacheStore therefore declares itself shared for warm.

  2. ivogt commented on Oct 4, 2026

    @ivogt
    CollaboratorAuthor

    Two more design decisions from the maintainer (2026-10-04).

    Shape: a runner bound to env and ctx, like router.prerender() after #640.

    const warm = router.warm({ env, ctx });
    await warm("/products/1");
    await warm.many(urls, { concurrency: 4, throwOnError: false });

    many() keeps a per-call concurrency (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 ppr shell, the route's cache() 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 layout cache() 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 /a never gets /b's tag), would add a KV marker read per tag on every hit, and would count against Vercel's 128-tag limit.

  3. changed the title [-]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)[/+] on Oct 4, 2026
  4. ivogt commented on Oct 4, 2026

    @ivogt
    CollaboratorAuthor

    Body rewritten to the decided design: one verb, router.prerender(), dispatched by route; no separate router.warm(). The earlier comments are kept for history; the body is current.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions