Skip to content

feat: seed memory cache from pre-rendered routes#645

Open
NathanDrake2406 wants to merge 2 commits intocloudflare:mainfrom
NathanDrake2406:feat/seed-memory-cache-from-prerender
Open

feat: seed memory cache from pre-rendered routes#645
NathanDrake2406 wants to merge 2 commits intocloudflare:mainfrom
NathanDrake2406:feat/seed-memory-cache-from-prerender

Conversation

@NathanDrake2406
Copy link
Contributor

Summary

  • Reads vinext-prerender.json at production server startup and populates the MemoryCacheHandler with pre-rendered HTML/RSC files from dist/server/prerendered-routes/
  • First request to any pre-rendered page is now a cache HIT instead of a full re-render
  • Adds buildId, router, and trailingSlash to the prerender manifest so the seeding function can construct matching cache keys and locate files correctly

Changes

File Change
src/server/seed-cache.ts New seedMemoryCacheFromPrerender() function
src/build/prerender.ts Add router to PrerenderRouteResult, add buildId/trailingSlash to manifest
src/build/run-prerender.ts Pass buildId/trailingSlash to writePrerenderIndex
src/server/prod-server.ts Call seeding at App Router production server startup
tests/seed-cache.test.ts 11 unit tests

Scope

App Router only for now. Pages Router seeding requires the prerender phase to also write pageData JSON files (not currently done), so it's left for a follow-up.

Closes #561

Test plan

  • 11 unit tests covering: ISR routes, static routes, dynamic routes, index route, trailingSlash layout, skipped/errored routes, missing manifest, missing HTML files, missing RSC files, multiple routes
  • Existing prerender tests (46) and ISR cache tests (42) pass unchanged
  • Type-aware lint clean

Read vinext-prerender.json and pre-rendered HTML/RSC files at production
server startup to populate the MemoryCacheHandler. This ensures the first
request to any pre-rendered page is a cache HIT instead of triggering a
full re-render.

- Add seedMemoryCacheFromPrerender() in server/seed-cache.ts
- Add buildId, router, and trailingSlash to the prerender manifest
- Call seeding during App Router production server startup
- 11 unit tests covering ISR routes, static routes, dynamic routes,
  trailingSlash, graceful degradation, and missing files

Closes cloudflare#561
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 22, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@645

commit: 0047beb

@james-elicx
Copy link
Collaborator

Only had a brief look to see if this handled Workers as well - do you have any thoughts on how this could be adapt to support that environment as well? Admittedly there would be gotcha's we need to make sure people are aware of if they use the memory cache with Workers, but it would be good to stub that with the prerendered routes rather than just SSR.

@NathanDrake2406
Copy link
Contributor Author

Only had a brief look to see if this handled Workers as well - do you have any thoughts on how this could be adapt to support that environment as well? Admittedly there would be gotcha's we need to make sure people are aware of if they use the memory cache with Workers, but it would be good to stub that with the prerendered routes rather than just SSR.

I'll do that in a follow up

@NathanDrake2406
Copy link
Contributor Author

Oh my god I need to ref the issue instead of closing

@NathanDrake2406 NathanDrake2406 marked this pull request as draft March 22, 2026 11:33
@NathanDrake2406
Copy link
Contributor Author

@james-elicx what's ur idea? KV? 🤔

@james-elicx
Copy link
Collaborator

@james-elicx what's ur idea? KV? 🤔

I had made a separate issue for remote caches like KV (#562) because it would be a different mechanism to seeding an in-memory cache.

KV is opt-in for Workers deployments, so the default which would be the memory cache should be able to use the prerendered files ideally.... But we should encourage people to use a custom handlers for deployments to environments with short-lived compute.

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review March 22, 2026 15:34
@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good feature — seeding the memory cache from pre-rendered routes is the obvious thing to do for cold-start performance. The implementation is clean and well-tested. A few issues to address, one of which is a correctness bug:

Key issues:

  1. Bug: trailingSlash: false silently omitted from manifest — the falsy check means the explicit false value is never written, which happens to work today due to the ?? false default on read, but is fragile and misleading.
  2. Missing setRevalidateDuration() calls — without populating the revalidate duration map, the ISR layer can't emit correct Cache-Control headers on seeded entries (if the App Router production path reads this map for seeded entries).
  3. Synchronous file I/O at startupreadFileSync in a loop over many routes could add noticeable latency to server startup for large sites.
  4. Silent failure on manifest parse errors — worth at least a warning log so operators can diagnose corrupt builds.

const index = { routes: indexRoutes };
const index = {
...(buildId ? { buildId } : {}),
...(trailingSlash ? { trailingSlash } : {}),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (latent): trailingSlash is only written to the manifest when truthy. Since the config type is boolean, trailingSlash: false is intentionally never serialized. This works today because seed-cache.ts defaults to false, but it means the manifest format is lossy — you can't distinguish "not set" from "explicitly false".

The safer pattern is to always write it when the value is a boolean, regardless of truthiness:

Suggested change
...(trailingSlash ? { trailingSlash } : {}),
...(typeof trailingSlash === "boolean" ? { trailingSlash } : {}),

This way trailingSlash: false is explicitly recorded in the manifest and the reader doesn't need to guess the default.

try {
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
} catch {
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silently swallowing parse errors makes corrupt manifests very hard to diagnose. Consider logging a warning:

Suggested change
return;
} catch (err) {
console.warn("[vinext] Failed to parse vinext-prerender.json, skipping cache seeding:", err);
return;

const revalidateCtx =
typeof route.revalidate === "number" ? { revalidate: route.revalidate } : {};

await seedAppRoute(handler, prerenderDir, pathname, buildId, trailingSlash, revalidateCtx);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing setRevalidateDuration() call. The ISR layer in isr-cache.ts maintains a separate revalidateDurations map used to emit Cache-Control headers on cache hits. If the App Router production path ever reads getRevalidateDuration() for seeded entries, it'll get undefined and fall back to a default (or skip the header entirely).

I checked and the App Router RSC entry currently doesn't use getRevalidateDuration() (only the Pages Router dev server does), so this isn't a live bug today. But it's a correctness gap — if you're seeding the cache to look like a real ISR entry, you should also seed the revalidate duration so the abstraction is complete:

Suggested change
await seedAppRoute(handler, prerenderDir, pathname, buildId, trailingSlash, revalidateCtx);
await seedAppRoute(handler, prerenderDir, pathname, buildId, trailingSlash, revalidateCtx);
// Also populate the revalidate duration map so Cache-Control headers
// are correct on first HIT (if the serving path reads it).
if (typeof route.revalidate === "number") {
const { setRevalidateDuration } = await import("./isr-cache.js");
const baseKey = isrCacheKey("app", pathname, buildId);
setRevalidateDuration(baseKey + ":html", route.revalidate);
setRevalidateDuration(baseKey + ":rsc", route.revalidate);
}

(Or import setRevalidateDuration at the top and call it inside seedAppRoute alongside the handler.set calls.)

const htmlFullPath = path.join(prerenderDir, htmlRelPath);
if (!fs.existsSync(htmlFullPath)) return;

const html = fs.readFileSync(htmlFullPath, "utf-8");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the file reads in seedAppRoute (existsSync + readFileSync, called twice per route) are synchronous. For a handful of routes this is fine, but for a large site with hundreds of pre-rendered routes, this could add measurable startup latency since each readFileSync blocks the event loop.

Not a blocker for this PR, but worth a // TODO: comment noting that this could be migrated to fs/promises + Promise.all batching if startup time becomes a concern:

Suggested change
const html = fs.readFileSync(htmlFullPath, "utf-8");
const html = fs.readFileSync(htmlFullPath, "utf-8");
// TODO: Consider fs/promises for large route sets to avoid blocking the event loop at startup.
const baseKey = isrCacheKey("app", pathname, buildId);

const rscValue: CachedAppPageValue = {
kind: "APP_PAGE",
html: "",
rscData: rscBuffer.buffer.slice(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Buffer.prototype.buffer returns the underlying ArrayBuffer, which may be a pool allocation larger than the Buffer itself. The .slice() here correctly handles that. However, in Node.js 20+ Buffer has a .buffer that returns a proper ArrayBuffer when the Buffer owns the whole thing, but for pooled small buffers it still shares.

The code is correct as-is. Just noting that an alternative is new Uint8Array(rscBuffer).buffer which always produces a right-sized copy, though that's an extra allocation. Current approach is fine.


// Seed the memory cache with pre-rendered routes so the first request to
// any pre-rendered page is a cache HIT instead of a full re-render.
await seedMemoryCacheFromPrerender(path.dirname(rscEntryPath));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good placement — after the RSC module import but before the HTTP server starts listening. This guarantees all seeded entries are in the cache before any request arrives.

Minor: should this log the number of routes seeded? A single info line like [vinext] Seeded N pre-rendered routes into memory cache would be helpful for operators to confirm the feature is working. Silent success can be hard to distinguish from silent no-op.

@@ -0,0 +1,361 @@
/**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid test coverage — the 11 tests cover the important cases (ISR, static, dynamic, index, trailingSlash, skipped, errored, missing files, multiple routes). A few cases that would strengthen coverage for future work:

  1. Corrupt/malformed manifest JSON (e.g., truncated file) — verifies the catch branch doesn't throw
  2. Missing buildId in manifest — verifies the early return
  3. Very long pathname that triggers the FNV hash path in isrCacheKey — ensures seeded keys match what the RSC entry would generate

Not blocking — just suggestions for follow-up.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 22, 2026

Review posted successfully. Here's a summary of the feedback I left on PR #645:

Key findings:

  1. trailingSlash: false omitted from manifest — The ...(trailingSlash ? { trailingSlash } : {}) pattern silently drops false values. While the read side defaults to false, this makes the manifest format lossy. Suggested using typeof trailingSlash === "boolean" instead.

  2. Missing setRevalidateDuration() calls — When seeding the cache, the revalidate duration map in isr-cache.ts is not populated. Currently not a live bug (App Router RSC entry doesn't read it), but it's a completeness gap that could bite when the serving path starts using it.

  3. Silent failure on manifest parse errors — The catch {} block swallows parse errors without any logging, making corrupt manifests very hard to diagnose. Suggested adding a console.warn.

  4. Synchronous I/O in a loopreadFileSync for each route could slow startup for large sites. Not a blocker, but suggested a TODO comment.

  5. Prod server integration — Good placement before the HTTP server starts, but suggested logging the number of seeded routes for operator visibility.

  6. Tests — Solid coverage across 11 cases. Suggested a few additional edge cases (corrupt JSON, missing buildId, long pathnames hitting the hash path) for follow-up.

github run

NathanDrake2406 added a commit to NathanDrake2406/vinext that referenced this pull request Mar 23, 2026
- Warn on corrupt manifest JSON instead of silent swallow
- Always serialize trailingSlash (typeof boolean, not truthiness)
- Populate setRevalidateDuration() for seeded entries so
  Cache-Control headers are correct on first HIT
- Return seeded route count and log it at startup
- Add tests: corrupt JSON, missing buildId, FNV hash path,
  revalidate duration tracking, return value assertions

Addresses review comments on cloudflare#645.
- Warn on corrupt manifest JSON instead of silent swallow
- Always serialize trailingSlash (typeof boolean, not truthiness)
- Populate setRevalidateDuration() for seeded entries so
  Cache-Control headers are correct on first HIT
- Return seeded route count and log it at startup
- Add tests: corrupt JSON, missing buildId, FNV hash path,
  revalidate duration tracking, return value assertions

Addresses review comments on cloudflare#645.
@NathanDrake2406 NathanDrake2406 force-pushed the feat/seed-memory-cache-from-prerender branch from 0047beb to 2b744d3 Compare March 23, 2026 01:19
@NathanDrake2406
Copy link
Contributor Author

@james-elicx istg if they isn't approved I'll murder bonk I ran like 3 passes of reviews

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serve pre-rendered responses from memory cache

2 participants