Skip to content

perf(build): parallelize prerender across a pool of render processes - #2437

Merged
james-elicx merged 2 commits into
cloudflare:mainfrom
hyfdev:perf/prerender-parallel
Jun 30, 2026
Merged

perf(build): parallelize prerender across a pool of render processes#2437
james-elicx merged 2 commits into
cloudflare:mainfrom
hyfdev:perf/prerender-parallel

Conversation

@hyfdev

@hyfdev hyfdev commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Problem

vinext build (with --prerender-all, output: 'export', or a prerender config) renders every static route by fetching it from a single in-process production server, driven by a bounded promise pool (runWithConcurrency in build/prerender.ts). React SSR/RSC rendering is CPU-bound JS, so the promise pool only overlaps I/O — every render serializes on one core, and raising --prerender-concurrency changes nothing. Next.js forks a worker pool and saturates every core. On static-heavy sites vinext therefore wins the bundle phase (rolldown) but loses the prerender phase, and loses total build time.

Change

Fork a pool of production-server child processes (one per core) and round-robin the per-route render fetch across them. All orchestration — route scanning, getStaticPaths/generateStaticParams resolution, file writing, the manifest — stays on the main process; only where the render runs changes. The render path is otherwise untouched.

  • build/prerender-server-pool.ts (new): startPrerenderServerPool(outDir, size) forks size startProdServer children, each reporting its ephemeral port over IPC. resolvePrerenderPoolSize(routeCount, maxOverride) scales by cores (availableParallelism() - 1, capped at 8), available memory, and routes (≥48/worker), returning 1 (= no fork, current behavior) for small apps, low-memory/low-core machines, or --prerender-concurrency 1.
  • build/prerender-server-entry.ts (new): the child entry — installs the same NoOpCacheHandler the in-process prerender path uses, then startProdServer({ port: 0, purpose: "prerender" }).
  • build/prerender.ts: prerenderPages and prerenderApp keep the in-process server for the resolution endpoints; when they own the server (non-hybrid), the worker entry exists (built package), and the pool size > 1, they fork the pool and round-robin renders across the child ports, closing it in finally. Hybrid (_prodServer) is unchanged. Running from source (no built .js entry) falls back to single-process.

child_process, not worker_threads: worker threads contend for CPU on this workload — measured ~2× slower per route and non-scaling — which is also why Next.js uses processes.

Results — react.dev (real app, 809 routes)

Same machine (14-core), cold (.next/dist + MDX node_modules/.cache + Vite cache cleared before each run), phase-split via per-line timestamps. Next is built from a pristine react.dev tree (the app's real webpack build — see note); vinext is rolldown. Both emit 809 pages.

Phase vinext — multi-worker (this PR) vinext — single (today) Next.js (webpack) faster
Bundler (rolldown / webpack) ~6.9 s ~6.5 s ~8.6 s 🟢 vinext
Prerender (MDX compile + render) ~7.7 s ~22.0 s ~10.5 s 🟢 vinext (pool) / 🔴 single
Finalize + traces ~0 ~0 ~0.5 s
Total ~14.6 s ~28.6 s ~19.7 s 🟢 vinext (pool)

The prerender phase is the whole story: single-process loses it 2.1× (22.0 vs 10.5), and the pool flips it to a win (7.7 vs 10.5) — so total goes from 1.45× slower than Next to 1.35× faster. (Totals corroborated across 2 full runs: pool ~15.1 s, single ~29.9 s, Next ~19.3 s.)

Why webpack for react.dev? react.dev's next.config.js uses webpack-only loaders/plugins (raw-loader, NormalModuleReplacementPlugin, IgnorePlugin), and Next 15.1.12's next build has no --turbopack option — so its real build is webpack. The bundle phase is anyway not what this PR changes; the prerender phase is bundler-independent.

Results — synthetic 801-route static fixture (vs Next 16 + Turbopack)

So the win isn't just "rolldown vs webpack" — same machine, cold, vs Next 16's Turbopack build:

Phase vinext — multi-worker Next.js 16 (Turbopack)
Bundler ~0.5 s ~0.5 s
Prerender (data + static-gen) ~1.6 s ~1.3–3.0 s
Finalize ~0 ~0.7 s
Total ~2.1 s ~2.8–4.4 s (first-ever-cold ~8.4 s)

vinext (multi-worker) is faster than Turbopack-Next on total here too; the Next numbers are noisier (Turbopack engine + worker warmup).

Correctness

  • On a deterministic fixture, prerender output is byte-identical single-process vs pooled (same bundle).
  • Workers install the same NoOpCacheHandler as the in-process path (the handler is a process-global), so no ISR/unstable_cache/fetch-cache reuse leaks across routes within a worker.
  • A render worker that exits unexpectedly (e.g. OOM kill) fails the build loudly via assertHealthy() rather than emitting partial output and exiting 0; children exit on parent disconnect so they don't orphan; a signal-killed startup fails fast instead of waiting out the readiness timeout.

Tuning

Existing --prerender-concurrency N caps the render-process count. Default = cores − 1, max 8.

Out of scope (separate issue)

vinext's prerender uses streaming renderToReadableStream, so a Suspense boundary backed by a lazy import() serializes in streamed-deferred form on a cold render and inline-resolved form when warm (reproducible single-process — not caused by this change). Deterministic SSG output would use react-dom/static's prerender.

@pkg-pr-new

pkg-pr-new Bot commented Jun 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2437
npm i https://pkg.pr.new/vinext@2437

commit: 6a4bad1

Build-time prerender rendered every static route by fetching it from a
single in-process production server driven by a promise pool. React
SSR/RSC rendering is CPU-bound JS, so the pool only overlaps I/O — every
render serialized on one core, and raising --prerender-concurrency did
nothing. Next.js forks a worker pool and saturates every core.

Fork a pool of production-server child processes (one per core, capped)
and round-robin the per-route render fetch across them, keeping route
scanning, getStaticPaths/static-params resolution, file writing and the
manifest on the main process. Pool size scales by cores AND routes, so
small apps, low-memory machines, and --prerender-concurrency 1 keep the
single in-process server (no fork, no regression); running from source
(no built .js worker entry) also falls back to single-process.

child_process, not worker_threads: worker threads contend for CPU on this
workload (measured ~2x slower per route and non-scaling), which is also
why Next.js uses processes.

react.dev (809 routes, cold cache, same machine): 29.9s -> 15.1s, now
faster than its own Next.js build (~19.3s). An 801-route static fixture:
~22s -> ~2-6s. Prerender output is byte-identical to the single-process
path on deterministic renders; workers install the same NoOp cache
handler the in-process path uses. A worker that exits unexpectedly fails
the build loudly instead of shipping partial output.
@hyfdev
hyfdev force-pushed the perf/prerender-parallel branch from 608720f to 9e013ac Compare June 30, 2026 16:27
@hyfdev
hyfdev marked this pull request as ready for review June 30, 2026 16:35
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the parallel-prerender pool. Overall this is a careful, well-documented change with good separation of concerns: orchestration (route scan, getStaticPaths/generateStaticParams resolution, secret endpoints, file writes, manifest) stays on the main process via baseUrl, and only the per-route render fetches are round-robined across forked workers via renderPorts. Worker/in-process parity is correctly maintained (both install NoOpCacheHandler; VINEXT_PRERENDER=1 is set before any module loads via the fork env), the hybrid _prodServer path is untouched, and prerenderPoolAvailable() correctly falls back to single-process when running from source. Crash handling (assertHealthy + disconnect-exit + signal-kill fast-fail) is thoughtful. vp check is clean on the new files and the sizing test passes.

A few non-blocking observations below — mostly comment/doc accuracy and test coverage. Nothing I'd consider a correctness blocker.

One thing I confirmed is not a bug: in prerenderApp.renderUrl, the HTML fetch and the RSC fallback fetch can land on two different workers (the round-robin counter advances per fetch). Because workers are stateless and the cache handler is a NoOp, this is safe — worth being aware of, but no action needed.

// Leave one core for the main thread (fetch + file writes) and OS.
const byCores = Math.max(1, Math.min(cores - 1, MAX_POOL_SIZE));
// Cap by available memory so K bundles don't OOM on constrained CI.
const byMemory = Math.max(1, Math.floor(os.totalmem() / APPROX_BYTES_PER_WORKER));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Memory cap uses os.totalmem() but the doc comments ("Cap by available memory", "so K render processes don't OOM on constrained CI") describe available memory. totalmem ignores memory already consumed by the main build process (full bundle + in-process prod server, also hundreds of MB) and anything else on the box. On a tight 2GB CI runner this yields floor(2GB/768MB)=2 workers, but main + in-process server + 2 workers can still exceed 2GB. assertHealthy() does catch the resulting OOM-kill loudly, so this is a tuning/wording nit rather than a correctness issue — but consider either (a) reserving a budget for the main process here, or (b) softening the comment from "available memory" to "total memory (coarse upper bound)" so the intent isn't misread later.

// A child killed by signal reports code === null; reject on either so an
// OOM-killed (SIGKILL) startup fails fast instead of waiting out the 60s
// readiness timeout.
child.once("exit", (code, signal) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: this startup-exit listener is never removed after the child becomes ready, so once a ready worker later crashes, both this handler and the post-ready crash recorder (line 128) fire. The reject() here is a harmless no-op on an already-resolved promise, so this is fine as-is — just flagging that the double-registration is intentional/benign and might warrant a one-line comment to avoid a future "why two exit handlers?" question.

if (typeof process.send === "function") {
process.send({ type: "error", error: message });
}
// Give the IPC message a tick to flush before exiting.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The 50ms setTimeout before process.exit(1) is a heuristic to flush the IPC error message. If serialization of a large stack takes longer than 50ms the parent may miss the message and instead surface the generic startup-exit rejection (code 1) without the real error text. Low-risk, but child.send/process.send accepts a callback (process.send(msg, () => process.exit(1))) which would exit deterministically once the message is flushed rather than racing a fixed timer.


expect(resolvePrerenderPoolSize(384, 1)).toBe(1);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test coverage is limited to resolvePrerenderPoolSize (pure sizing math). The riskier runtime surface — startPrerenderServerPool readiness aggregation, the crash→assertHealthy() path, and startup-exit fast-fail — has no test. I understand prerenderPoolAvailable() is false from source (the worker entry is .ts, not a forkable .js), which makes the real fork path hard to unit-test here. Still worth considering a focused test that forks a tiny stub entry (or factors the IPC ready/crash bookkeeping into a pure helper) so assertHealthy()'s "fail the build loudly" guarantee — the main correctness claim of the PR — is actually exercised in CI rather than only validated manually.

@ask-bonk

ask-bonk Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully: #2437 (review)

Summary

I reviewed PR #2437 (parallelize prerender across a pool of render processes) and posted a batch review with 4 inline comments. No correctness blockers found.

What's solid:

  • Clean separation: orchestration/resolution/secret-endpoints/file-writes/manifest stay on the main process (baseUrl); only per-route render fetches round-robin across forked workers (renderPorts).
  • Worker↔in-process parity verified: both install NoOpCacheHandler, and VINEXT_PRERENDER=1 is set via fork env before any module loads (matters because some server modules read the flag at import time).
  • Hybrid _prodServer path untouched; prerenderPoolAvailable() correctly degrades to single-process when running from source.
  • Crash handling (assertHealthy() + disconnect-exit + signal-kill fast-fail) prevents partial output / orphaned servers.
  • vp check clean on new files; sizing test passes.

Non-blocking observations raised:

  1. Memory cap uses os.totalmem() but comments say "available memory" — ignores main-process footprint; mitigated by assertHealthy() but worth wording/budget fix.
  2. Startup exit listener double-registers after ready (benign no-op reject) — could use a clarifying comment.
  3. Child error-flush uses a 50ms setTimeout heuristic; process.send(msg, cb) would exit deterministically.
  4. Test coverage is limited to the pure sizing math; the riskier startPrerenderServerPool/crash/assertHealthy() runtime path — the PR's main correctness claim — isn't exercised in CI.

I also confirmed a potential concern that turned out not to be a bug: the HTML and RSC-fallback fetches for one App Router route can hit different workers, which is safe since workers are stateless with a NoOp cache.

github run

@james-elicx
james-elicx force-pushed the perf/prerender-parallel branch from 3ab93e0 to e9f007f Compare June 30, 2026 17:38
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the parallel-prerender pool. The design is solid and the separation of concerns is exactly right: route scanning, getStaticPaths/generateStaticParams resolution, and the secret /__vinext/prerender/* endpoints all stay on the in-process server via baseUrl, and only the per-route render fetches round-robin across renderPorts. Worker/in-process parity is correct (both install NoOpCacheHandler; VINEXT_PRERENDER=1 is set via the fork env before any module loads; forked workers read the prerender secret from the same outDir on disk). Hybrid _prodServer is untouched, and prerenderPoolAvailable() cleanly degrades to single-process from source. The runtime tests now cover startPrerenderServerPool start/close, the crash to assertHealthy() path, and startup-exit fast-fail — addressing the earlier coverage gap.

One genuine (minor) robustness concern around the assertHealthy() timing, plus a couple of smaller notes. Nothing I'd consider a correctness blocker.

I also re-confirmed the RSC-fallback fetch landing on a different worker than the HTML fetch (round-robin advances per fetch) is safe — workers are stateless with a NoOp cache.

// A worker that crashed mid-render makes its routes fail with connection
// errors that are otherwise recorded as non-fatal — fail the build loudly
// instead of shipping partial output.
renderPool?.assertHealthy();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The assertHealthy() guarantee has a theoretical timing gap on the last render. crash is set by the child's async exit event, but assertHealthy() is called synchronously right after await runWithConcurrency(...). The in-flight fetch() to a dying worker rejects on a socket error (ECONNRESET/ECONNREFUSED), and the child exit event is an independent libuv event with no ordering guarantee relative to that socket error. So if a worker crashes on the final route(s), assertHealthy() can run before crash is populated, and the build could still emit partial output and exit 0 — the exact failure this guards against.

In practice it's well mitigated: after a crash, every subsequent route routed to the dead port also fails, and draining those failures buys enough event-loop time for exit to fire. The test even sleeps 100ms before asserting, which acknowledges this dependency. Worth a short comment noting the reliance on the loop draining, or consider making assertHealthy() also fail when accumulated per-route connection errors exceed a threshold, so the guarantee doesn't hinge purely on exit timing.

// No custom 404
}
}
renderPool?.assertHealthy();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: just above, the 404 render's renderPage(...) fetch can land on a crashed worker's port and reject, which the surrounding catch {} silently swallows as "No custom 404" — so an existing custom 404 would be quietly skipped. It's recovered because this assertHealthy() still surfaces the crash and fails the build, so output is never shipped silently. Flagging that the swallowed connection error there is intentionally relying on this assertHealthy() to be the real signal; a one-line comment would prevent a future "why is the 404 silently missing?" investigation.

* Used with total system memory as a coarse upper bound on constrained machines
* so K render processes don't OOM. Conservative — measured ~260-380 MB/worker
* for a mid-size app. */
const APPROX_BYTES_PER_WORKER = 768 * 1024 * 1024;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

APPROX_BYTES_PER_WORKER = 768MB is roughly 2-3x the measured per-worker footprint (the comment says ~260-380 MB/worker). Combined with the new APPROX_MAIN_PROCESS_BYTES reservation this is appropriately conservative for avoiding OOM, but on, say, a 16-core / 8GB box it caps workers at floor((8GB-768MB)/768MB)=9 → effectively core-bound, whereas the actual memory could comfortably host more. That's the safe direction (under-fork rather than OOM), so not a blocker — just noting the budget is coarse and may leave throughput on the table on high-core/moderate-memory machines.

@ask-bonk

ask-bonk Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully: #2437 (review)

Summary

I reviewed PR #2437 (parallelize prerender across a pool of render processes) and posted a batch review with 3 inline comments. No correctness blockers.

What's solid:

  • Clean separation — route scanning, getStaticPaths/generateStaticParams resolution, and secret /__vinext/prerender/* endpoints stay on the in-process server (baseUrl); only per-route render fetches round-robin across forked workers (renderPorts).
  • Worker/in-process parity verified: both install NoOpCacheHandler, VINEXT_PRERENDER=1 is set via fork env before modules load, and workers read the prerender secret from the same outDir on disk.
  • Hybrid _prodServer path untouched; prerenderPoolAvailable() correctly degrades to single-process from source.
  • Crash handling (assertHealthy() + disconnect-exit + signal-kill fast-fail) is thoughtful.
  • Confirmed the RSC-fallback-vs-HTML fetch landing on different workers is safe (stateless + NoOp cache).
  • Unlike the earlier review, I confirmed the runtime tests do exist now (start/close stub workers, crash→assertHealthy, startup-exit reject) — the prior coverage gap is addressed.

Non-blocking observations raised:

  1. assertHealthy() timing race on the last render — crash is set by an async exit event with no ordering guarantee vs the socket-error rejection from a dying worker, so a final-route crash could theoretically slip through. Well-mitigated by the loop draining, but worth a comment or an accumulated-connection-error fallback.
  2. 404 render swallows connection errors — a crashed-worker fetch is silently caught as "no custom 404"; recovered by the following assertHealthy(), but worth a clarifying comment.
  3. APPROX_BYTES_PER_WORKER (768MB) is ~2-3x the measured footprint, leaving throughput on the table on high-core/moderate-memory machines — safe direction, just coarse.

github run

@james-elicx
james-elicx force-pushed the perf/prerender-parallel branch from e9f007f to 2994e18 Compare June 30, 2026 17:44
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the parallel-prerender pool again, focusing on cross-path parity between the Pages Router and App Router prerender flows. The design is sound and most concerns from the earlier reviews still hold. One concrete asymmetry stands out that I'd treat as a real (if low-probability) parity bug rather than a nit, plus a couple of smaller notes.

App Router 404 catch is missing the recordRenderError that the Pages Router 404 catch got.

The Pages Router 404 path was correctly updated to record transport failures (prerender.ts:946-951):

} catch (e) {
  // No custom 404. When the render-worker pool is active, a transport
  // failure here is still captured by assertHealthy() below ...
  renderPool?.recordRenderError(e);
}

but the App Router 404 path (prerender.ts:1620-1622) still swallows silently:

} catch {
  // No custom 404 — skip silently
}

This matters because the App Router 404 fetch goes through rscHandler, which round-robins across renderPorts (prerender.ts:1082) — so it can land on a crashed worker just like a normal route. The whole reason recordRenderError exists (per its own docstring at prerender-server-pool.ts:72-75) is to close the timing gap where the request rejects with a socket error before Node delivers the child exit event. The following renderPool?.assertHealthy() at prerender.ts:1623 only inspects the exit-driven crash field and the recorded renderTransportError; if the 404 fetch hits a dead worker and the exit event hasn't fired yet, nothing is recorded and assertHealthy() passes, so the build writes the manifest and exits 0 having silently skipped an existing custom 404. That is exactly the failure the Pages Router fix guards against. The two router paths should behave identically here.

The remaining items are non-blocking and overlap with prior reviews.

@@ -1546,6 +1620,7 @@ export async function prerenderApp({
} catch {
// No custom 404 — skip silently
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This App Router 404 catch should mirror the Pages Router 404 catch (prerender.ts:946-951), which was updated to record transport errors. The 404 fetch here goes through rscHandler, which round-robins across renderPorts (line 1082), so it can hit a crashed worker. The assertHealthy() on the next line only sees the exit-driven crash field and any recorded renderTransportError — without recording here, a socket error that beats the exit event leaves nothing for assertHealthy() to catch, and the build can silently skip an existing custom 404 and still exit 0. That is the exact gap recordRenderError was added to close (see its docstring at prerender-server-pool.ts:72-75).

Suggested change
}
} catch (e) {
// No custom 404. When the render-worker pool is active, a transport
// failure here is still captured by assertHealthy() below so a crashed
// worker cannot silently skip an existing custom 404 (mirrors the
// Pages Router 404 path).
renderPool?.recordRenderError(e);
}

const byMemory = Math.max(1, Math.floor(memoryForWorkers / APPROX_BYTES_PER_WORKER));
const cap = maxOverride && maxOverride > 0 ? Math.min(maxOverride, byCores) : byCores;
const byRoutes = Math.floor(routeCount / MIN_ROUTES_PER_WORKER);
return Math.max(1, Math.min(cap, byMemory, byRoutes));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note the interaction with DEFAULT_CONCURRENCY = min(cores, 8) (prerender.ts:255): concurrency is passed as maxOverride here and used as the in-flight fetch limit in runWithConcurrency. So by default the pool size is silently capped at min(cores, 8) workers as well as at cores - 1. That's a fine default, but it means the documented "one per core" only holds up to 8 cores and the cap is effectively min(cores-1, 8, concurrency). Worth a one-line note so a future reader doesn't expect more workers than the concurrency limit allows.

// assertHealthy() can fail the build instead of shipping partial output.
child.once("exit", (code, signal) => {
if (!shuttingDown && !crash) {
crash = { port: msg.port, code, signal };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

crash only ever records the first unexpected exit (if (!shuttingDown && !crash)). That's fine for failing the build, but combined with the per-fetch round-robin it means: if worker A crashes, assertHealthy() fires (good); but if assertHealthy() at an earlier checkpoint already passed and a second worker dies later, the message still names the first port. Purely cosmetic for the error text — flagging only so the single-slot design is intentional and not mistaken for "records every crash."

@ask-bonk

ask-bonk Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully: #2437 (review)

Summary

I focused this review on cross-path parity between the Pages Router and App Router prerender flows, since the earlier two reviews already covered the general design, crash handling, and sizing math.

One concrete finding (the main one):

  • App Router 404 catch is missing the recordRenderError the Pages Router 404 catch got. The Pages Router 404 swallow at prerender.ts:946-951 was updated to renderPool?.recordRenderError(e), but the App Router 404 swallow at prerender.ts:1620-1622 was left as silent catch {}. The App Router 404 fetch goes through rscHandler, which round-robins across renderPorts (prerender.ts:1082), so it can hit a crashed worker. The following assertHealthy() only inspects the exit-driven crash field plus recorded transport errors — so if the 404 fetch rejects with a socket error before the child exit event fires, nothing is recorded, assertHealthy() passes, and the build can silently skip an existing custom 404 and still exit 0. That's exactly the gap the Pages Router fix closes. Posted a suggestion to mirror it. The prior reviews flagged the Pages Router 404 swallow but didn't notice the App Router one wasn't given the same treatment.

Two non-blocking notes:

  • The default pool size is silently capped at min(cores, 8) because DEFAULT_CONCURRENCY is passed as maxOverride and reused as the in-flight fetch limit — worth documenting so "one per core" isn't misread.
  • crash records only the first unexpected exit, so the error text always names the first dead port — intentional but worth a note.

No correctness blockers beyond the App Router 404 asymmetry, which is low-probability (only manifests on a worker crash during the final 404 render) but is a genuine dev/prod-style parity gap between the two router paths.

github run

@james-elicx
james-elicx force-pushed the perf/prerender-parallel branch from 2994e18 to ad426ce Compare June 30, 2026 17:49
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed with a focus on what the three prior /bigbonk passes didn't cover: the App Router 404 recordRenderError asymmetry has already been fixed in ad426ce, and crash handling / sizing math are well-covered. I looked instead at whether moving the render into a forked worker changes any manifest metadata (not just the HTML/RSC bytes), since the PR's correctness claim is byte-identical output.

One concrete finding: the App Router prerender derives revalidate/expire for the manifest partly from the request-scoped cache-life process-global (_consumeRequestScopedCacheLife()), which no longer lives in the same process as the render once the pool is forked. In the common case this is recovered from the response Cache-Control header, but there's one branch where it isn't — details inline. It's a narrow cacheLife({ expire <= revalidate }) case, so I'd treat it as a non-blocking parity note rather than a blocker, but it does mean pooled vs single-process output is not strictly identical for those routes.

Everything else looks solid. Nice, well-scoped change.

...(isFallback ? { fallback: true } : {}),
};
} catch (e) {
renderPool?.recordRenderError(e);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Parity note (App Router only), about the cache-life metadata feeding resolveRenderedCacheControl a few lines below (line 1525-1526):

htmlRender.requestCacheLife comes from _consumeRequestScopedCacheLife() (line 1440), which reads a process-global cache-life set during render. With the pool active the render runs in a forked worker, so this global is empty in the main process and requestCacheLife is always {}.

In the common case that's fine — resolveRenderedCacheControl falls back to parsing s-maxage + stale-while-revalidate from the response Cache-Control header (line 1656-1659), which the worker emits via buildRevalidateCacheControl. But buildRevalidateCacheControl returns a bare s-maxage=R (no stale-while-revalidate) when revalidate >= expire (cache-control.ts:57-58). In that case the header fallback yields staleWhileRevalidate === undefined, so revalidate resolves to undefined and expire falls back to config.expireTime — whereas the single-process path would have read the real R/E from requestCacheLife.

Net effect: for a route using cacheLife({ revalidate, expire }) with expire <= revalidate, the manifest revalidate/expire differ between single-process and pooled builds. The rendered HTML/RSC bytes are still identical, but the cache metadata in vinext-prerender.json isn't — which slightly undercuts the "byte-identical" correctness claim for those routes. Low-probability (named cacheLifeProfiles all have revalidate < expire), but worth either documenting as a known gap or having the worker round-trip the cache life back to the main process (e.g. a response header) so the manifest matches single-process exactly.

function isWorkerTransportError(err: unknown): boolean {
const code = getErrorCauseCode(err);
if (code && PRERENDER_WORKER_TRANSPORT_ERROR_CODES.has(code)) return true;
return err instanceof TypeError && err.message === "fetch failed";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

isWorkerTransportError also matches any bare TypeError("fetch failed"). Combined with assertHealthy() throwing on the first recorded transport error, a single transient socket failure from an otherwise-healthy worker (e.g. a brief connection reset under load, not a crash) would fail the whole build with the OOM-flavored message. That's the safe direction (fail loud rather than ship partial output), but it means the pool path is slightly less tolerant of transient I/O than the single-process path, which would surface the one route as a normal render error. Worth a comment noting this is intentional, or scoping the transport-error fatality to cases where a worker exit was also observed.

let crash: { port?: number; code: number | null; signal: NodeJS.Signals | null } | null = null;
let renderTransportError: Error | null = null;

const close = async (): Promise<void> => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

close() fires SIGKILL but resolves synchronously without awaiting the children's exit, so await renderPool.close() in the finally returns before the OS has reaped the processes. In practice the disconnect handler in the entry plus SIGKILL make orphans unlikely, but if the build process exits immediately after close() resolves there's a small window where killed-but-not-yet-reaped servers still hold their ports. If you want close() to actually mean "all workers are gone," await each child's exit (with a short timeout) before resolving.

@ask-bonk

ask-bonk Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully: #2437 (review)

Summary

I reviewed PR #2437 (parallelize prerender across a pool of render processes), deliberately covering ground the three prior /bigbonk passes didn't. The App Router 404 recordRenderError asymmetry flagged earlier is already fixed in ad426ce; crash handling, sizing math, and worker/in-process parity are all solid. No correctness blockers.

I focused on whether forking the render into a child process changes any manifest metadata (not just the HTML/RSC bytes the PR claims are byte-identical), since that's the one thing process isolation can silently break.

One concrete finding (non-blocking parity gap):

  • App Router cache-life metadata divergence. The manifest's revalidate/expire are derived partly from _consumeRequestScopedCacheLife() (prerender.ts:1440), a process-global set during render. With the pool active, the render runs in a forked worker, so that global is empty in the main process. It's normally recovered from the response Cache-Control header — except when buildRevalidateCacheControl emits a bare s-maxage=R (no stale-while-revalidate) for revalidate >= expire (cache-control.ts:57-58). In that branch the header fallback yields undefined, so pooled builds produce different revalidate/expire in vinext-prerender.json than single-process builds for cacheLife({ expire <= revalidate }) routes. HTML/RSC bytes stay identical; the cache metadata doesn't. Low-probability (all named cacheLifeProfiles have revalidate < expire), but it slightly undercuts the "byte-identical" claim.

Two smaller notes:

  • isWorkerTransportError matches any bare TypeError("fetch failed"), so a single transient socket failure from a healthy worker would fail the whole build (safe direction, but less I/O-tolerant than single-process).
  • close() SIGKILLs but resolves without awaiting exit, so await close() doesn't guarantee ports are released.

github run

@james-elicx
james-elicx force-pushed the perf/prerender-parallel branch from ad426ce to d1b0f42 Compare June 30, 2026 17:58
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

ask-bonk[bot]
ask-bonk Bot previously requested changes Jun 30, 2026

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed with a focus on what the four prior /bigbonk passes did not cover: the new VINEXT_PRERENDER_CACHE_LIFE_HEADER round-trip (added since the 4th review, which had flagged exactly this cache-life divergence — nice fix) and its interaction with the existing internal-header machinery.

CI blocker (main finding)

Adding VINEXT_PRERENDER_CACHE_LIFE_HEADER to VINEXT_INTERNAL_HEADERS (in packages/vinext/src/server/headers.ts) breaks an existing assertion in tests/request-pipeline.test.ts:794 that pins the array to exactly [VINEXT_PRERENDER_ROUTE_PARAMS_HEADER]. Confirmed failing locally:

FAIL tests/request-pipeline.test.ts > filterInternalHeaders > strips vinext-only internal headers ...
AssertionError: expected [ …(2) ] to deeply equal [ 'x-vinext-prerender-route-params' ]
- Expected
+ Received
  [
    "x-vinext-prerender-route-params",
+   "X-Vinext-Prerender-Cache-Life",
  ]

The required Vitest check will be red. Fix is a one-liner — update the toEqual at tests/request-pipeline.test.ts:794 to include the new header and add it to the imports at the top of that file:

expect(VINEXT_INTERNAL_HEADERS).toEqual([
  VINEXT_PRERENDER_ROUTE_PARAMS_HEADER,
  VINEXT_PRERENDER_CACHE_LIFE_HEADER,
]);

Confirmed NOT a leak

The new response header is gated behind isPrerender === true (requestCacheLifeForPrerender defaults to null, and applyPrerenderCacheLifeHeader early-returns on null), so it is never emitted on normal client responses. It is also not persisted into the static HTML/RSC files (only response.text() / arrayBuffer() bodies are written; the manifest stores only link). Adding it to VINEXT_INTERNAL_HEADERS for inbound stripping is correct defense-in-depth.

Parity note (non-blocking)

In the single-process path both responseCacheLife (parsed from the new header) and processCacheLife (_consumeRequestScopedCacheLife()) are populated, and responseCacheLife now wins via ?? (prerender.ts:1443). This is the intended fix for the pooled-vs-single cache-metadata divergence the 4th review flagged, and the new app-page-render tests confirm the header carries the same {revalidate, expire}. Worth a one-line comment that the header is now authoritative in both modes (and that _consumeRequestScopedCacheLife() is still consumed to drain the process-global / cover the no-header fallback) so a future reader doesn't assume the two can silently diverge.

The pool design, crash handling, and sizing math are solid and well-covered by the earlier reviews — this is just the one stale test.

@ask-bonk

ask-bonk Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review posted: #2437 (review)

Summary

I reviewed PR #2437, deliberately covering ground the four prior /bigbonk passes didn't: the new VINEXT_PRERENDER_CACHE_LIFE_HEADER round-trip (which was added after the 4th review and directly fixes the cache-life divergence that review flagged) and its interaction with the existing internal-header machinery.

One CI blocker found:

  • Adding VINEXT_PRERENDER_CACHE_LIFE_HEADER to VINEXT_INTERNAL_HEADERS breaks an existing toEqual assertion in tests/request-pipeline.test.ts:794 that pins that array to exactly [VINEXT_PRERENDER_ROUTE_PARAMS_HEADER]. I confirmed this fails locally (expected [ …(2) ] to deeply equal [ 'x-vinext-prerender-route-params' ]), so the required Vitest check will be red. Fix is a one-line test update plus an import. The prior four reviews didn't catch this because none of them ran the affected test or traced the new header into VINEXT_INTERNAL_HEADERS.

Confirmed safe (not a leak):

  • The new response header is gated behind isPrerender === true, never emitted on client responses, and never persisted into static HTML/RSC files or the manifest. Adding it to VINEXT_INTERNAL_HEADERS for inbound stripping is correct defense-in-depth.

One non-blocking parity note:

  • The header now wins over _consumeRequestScopedCacheLife() in both single- and pooled-process paths (prerender.ts:1443) — the intended fix for the divergence, verified by the new app-page-render tests. Worth a clarifying comment that the header is authoritative in both modes.

The pool design, crash handling, and sizing math (covered by the earlier reviews) are solid. The single actionable item is the stale test.

github run

@james-elicx
james-elicx force-pushed the perf/prerender-parallel branch from d1b0f42 to e604dfb Compare June 30, 2026 18:04
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sixth /bigbonk pass. The CI blocker from the fifth review (the stale VINEXT_INTERNAL_HEADERS toEqual assertion) is fixed — I ran the affected files locally and they're green:

✓ tests/prerender-server-pool.test.ts (7 tests)
✓ tests/request-pipeline.test.ts (102 tests)
✓ tests/app-page-render.test.ts (51 tests)

The pool design, crash handling, sizing math, and the cache-life header round-trip (which fixes the divergence the 4th review flagged) are all solid and well covered by the earlier passes. I re-verified the things process isolation can silently break:

  • Render/resolution split is correct. Secret resolution endpoints (/__vinext/prerender/static-params, pages-static-paths) use baseUrl (the in-process server, prerender.ts:696,1123); only render fetches round-robin across renderPorts (prerender.ts:1083,1420,1504,1611).
  • isPrerender parity in workers holds. It's derived from process.env.VINEXT_PRERENDER === "1" (app-page-dispatch.ts:1004), which the fork env sets before any module loads, so forked workers emit X-Vinext-Prerender-Cache-Life exactly like the in-process server.
  • renderPorts is initialized before rscHandler can run (prerender.ts:1066 vs the render loop), so no % 0 divide-by-zero despite the [] default.
  • App Router 404 recordRenderError (the 3rd review's finding) is present at prerender.ts:1631.

The _consumeRequestScopedCacheLife() global is still drained on the success path; responseCacheLife ?? processCacheLife correctly prefers the header in both modes.

One concrete finding the prior five passes missed: a latent header-casing inconsistency

The new constant is mixed-case: VINEXT_PRERENDER_CACHE_LIFE_HEADER = "X-Vinext-Prerender-Cache-Life". Every other header in VINEXT_INTERNAL_HEADERS (just VINEXT_PRERENDER_ROUTE_PARAMS_HEADER = "x-vinext-prerender-route-params") is lowercase, and that's load-bearing in a second consumer the prior reviews didn't trace:

packages/vinext/src/index.ts:4570-4575 (the dev-server inbound strip — not in this PR's diff, which is why it's easy to miss):

for (const header of VINEXT_INTERNAL_HEADERS) {
  delete req.headers[header];   // raw Node IncomingMessage
}

Node's HTTP parser always lowercases req.headers keys, so delete req.headers["X-Vinext-Prerender-Cache-Life"] never matches the stored x-vinext-prerender-cache-life key. A forged inbound header survives in req.headers.

This is the same root cause that forced the .map((header) => header.toLowerCase()) workaround you added in request-pipeline.ts:575 for the Web-Headers Set. That fix covers filterInternalHeaders(rawHeaders) (index.ts:4569) but not the raw req.headers delete loop two lines below it.

Severity: low / defense-in-depth, not an active hole. The cache-life header is only read at build time via case-insensitive headers.get() (prerender.ts:1681), never from runtime inbound req.headers, so a forged value can't influence runtime behavior today. But the loop's invariant ("all stripped internal-header constants are lowercase") is now silently violated, and any future code reading this header from req.headers would get the forged value.

Cleanest fix: make the constant lowercase (suggestion below). Header names are case-insensitive on the wire, so emitting via headers.set() and reading via headers.get() are unaffected — but the raw-req.headers strip at index.ts:4573 starts working, and you can then drop the request-pipeline.ts:575 .toLowerCase() workaround entirely. (Display-cased X-Vinext-* constants are fine because they're response-only and never appear in the inbound strip list; this one is in VINEXT_INTERNAL_HEADERS, so it should follow the lowercase convention of the headers that actually get stripped.)

Comment thread packages/vinext/src/server/headers.ts Outdated
export const VINEXT_DYNAMIC_STALE_TIME_HEADER = "X-Vinext-Dynamic-Stale-Time";

/** Prerender-only JSON side channel carrying request cacheLife metadata. */
export const VINEXT_PRERENDER_CACHE_LIFE_HEADER = "X-Vinext-Prerender-Cache-Life";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Define this lowercase to match VINEXT_PRERENDER_ROUTE_PARAMS_HEADER ("x-vinext-prerender-route-params"), the only other entry in VINEXT_INTERNAL_HEADERS:

Suggested change
export const VINEXT_PRERENDER_CACHE_LIFE_HEADER = "X-Vinext-Prerender-Cache-Life";
export const VINEXT_PRERENDER_CACHE_LIFE_HEADER = "x-vinext-prerender-cache-life";

Why: this header is added to VINEXT_INTERNAL_HEADERS (line 213), which is consumed in two inbound-strip sites. One is request-pipeline.ts (the Web-Headers Set — you correctly added .toLowerCase() at line 575 to handle the mixed case). The other is index.ts:4573, which does delete req.headers[header] on a raw Node IncomingMessage whose keys are always lowercased by Node — so the mixed-case constant never matches there and a forged x-vinext-prerender-cache-life survives the strip. Lowercasing the constant fixes that second site and lets you revert the request-pipeline.ts:575 workaround. Header names are case-insensitive on the wire, so headers.set()/headers.get() (app-page-response.ts:133, prerender.ts:1681) are unaffected.

@ask-bonk

ask-bonk Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully: #2437 (review)

Summary

Sixth /bigbonk pass on PR #2437. I confirmed the CI blocker from the fifth review (stale VINEXT_INTERNAL_HEADERS assertion) is fixed by running the three affected test files locally — all green (160 tests).

One concrete finding the prior five passes missed — a latent header-casing inconsistency:

The new VINEXT_PRERENDER_CACHE_LIFE_HEADER = "X-Vinext-Prerender-Cache-Life" is mixed-case, while the only other entry in VINEXT_INTERNAL_HEADERS is lowercase. That casing is load-bearing in a second consumer the prior reviews never traced: index.ts:4570-4575 does delete req.headers[header] on a raw Node IncomingMessage, whose keys Node always lowercases — so delete req.headers["X-Vinext-Prerender-Cache-Life"] never matches, and a forged inbound header survives the strip there. This is the same root cause that forced the .toLowerCase() workaround the author added in request-pipeline.ts:575, but that fix only covers the Web-Headers Set, not the raw-req.headers loop two lines below it in index.ts.

Severity is low / defense-in-depth — the header is only read at build time via case-insensitive headers.get(), never from runtime inbound req.headers, so it's not currently exploitable. But the invariant is silently violated and any future reader of this header from req.headers would get the forged value. Posted a suggestion to make the constant lowercase (which also lets the request-pipeline.ts:575 workaround be reverted). The prior reviews caught the request-pipeline.ts Set but none traced the index.ts strip loop because index.ts isn't in this PR's diff.

Confirmed solid (re-verified): render/resolution split (secret endpoints stay on baseUrl, only renders round-robin), isPrerender worker parity via fork env, renderPorts init ordering (no divide-by-zero), App Router 404 recordRenderError, and the _consumeRequestScopedCacheLife() drain + responseCacheLife ?? processCacheLife precedence. No correctness blockers.

github run

@james-elicx
james-elicx force-pushed the perf/prerender-parallel branch from e604dfb to b895a2d Compare June 30, 2026 18:10
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed PR #2437 again as an independent pass. The PR has converged nicely across the prior six /bigbonk rounds: the lowercase VINEXT_PRERENDER_CACHE_LIFE_HEADER constant fixes the index.ts:4573 raw-req.headers strip (verified the loop now matches the lowercased Node key, and the request-pipeline.ts .toLowerCase() workaround is gone), the App Router 404 recordRenderError is present (prerender.ts:1557/1631), renderPorts is initialized before any rscHandler fetch (no % 0), closeChild now awaits exit with a timeout, and the cache-life header round-trip is authoritative via responseCacheLife ?? processCacheLife. I ran the three affected files locally — tests/prerender-server-pool.test.ts, tests/request-pipeline.test.ts, tests/app-page-render.test.ts — all 160 green.

The render/resolution split is correct (secret endpoints + getStaticPaths/generateStaticParams stay on baseUrl; only renders round-robin across renderPorts), the pool outDir matches the in-process server's outDir in both routers, and setCacheHandler(new NoOpCacheHandler()) gives worker/in-process parity.

One new finding the prior passes didn't surface, plus two minor notes. No correctness blockers.

Main finding (a behavior regression risk): the pool is a pure perf optimization layered on top of an already-healthy in-process server, but if startPrerenderServerPool rejects, the whole build fails instead of degrading to single-process. A transient fork hiccup, an ephemeral-port race, or a slow CI box exceeding the 60s readiness timeout could newly fail a build that succeeds today — even though prodServer is up and renderPorts is still pointed at it. Inline below.

Comment thread packages/vinext/src/build/prerender.ts Outdated
const poolSize = resolvePrerenderPoolSize(pagesToRender.length, concurrency);
if (poolSize > 1) {
const poolOutDir = path.dirname(path.dirname(pagesBundlePath));
renderPool = await startPrerenderServerPool(poolOutDir, poolSize);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The render pool is a pure performance optimization stacked on top of the in-process prodServer, which is already started and fully capable of rendering every route (renderPorts defaults to [prodServer.port]). But startPrerenderServerPool is awaited inside the main try with no catch, so if pool startup rejects — a worker that fails readiness, an ephemeral-port race, a transient fork error, or a slow CI machine exceeding WORKER_READY_TIMEOUT_MS (60s) — the whole build fails.

This means a build that succeeds today (single in-process server) could newly fail purely because of a worker-startup hiccup, even though the in-process server is healthy and renderPorts still points at it. Pool render crashes correctly fail loud via assertHealthy() (that's output correctness), but pool startup failure is different: nothing has been rendered yet and the fallback is right there. Consider catching startup failure and degrading to single-process (log a warning, leave renderPorts as-is) rather than aborting the build:

Suggested change
renderPool = await startPrerenderServerPool(poolOutDir, poolSize);
if (!options._prodServer && pagesBundlePath && prerenderPoolAvailable()) {
const poolSize = resolvePrerenderPoolSize(pagesToRender.length, concurrency);
if (poolSize > 1) {
const poolOutDir = path.dirname(path.dirname(pagesBundlePath));
try {
renderPool = await startPrerenderServerPool(poolOutDir, poolSize);
renderPorts = renderPool.ports;
} catch (e) {
// Pool startup is a perf optimization; the in-process server can
// render everything. Degrade rather than failing the build.
console.warn(
`[vinext] prerender render pool failed to start; falling back to single-process render: ${
e instanceof Error ? e.message : String(e)
}`,
);
renderPool = null;
}
}
}

(Same applies to the App Router call at prerender.ts:1576.)

Comment thread packages/vinext/src/build/prerender.ts Outdated
if (!options._prodServer && prerenderPoolAvailable()) {
const poolSize = resolvePrerenderPoolSize(urlsToRender.length, concurrency);
if (poolSize > 1) {
renderPool = await startPrerenderServerPool(path.dirname(serverDir), poolSize);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as the Pages Router pool-start at line 851: a pool startup failure here aborts the whole build, even though the in-process prodServer (which renderPorts already points at) could render every route. Wrap in a try/catch and degrade to single-process on startup failure so the pool stays a strictly-additive optimization. (Render-time crashes should still fail loud via assertHealthy() — only startup failure should fall back.)

noCompression: true,
purpose: "prerender",
});
if (typeof process.send === "function") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: each forked worker starts with purpose: "prerender", so logProdServerStarted prints [vinext] Production server for prerendering running at ... once per worker (up to 8 lines on a high-core box) in addition to the in-process server's line. Cosmetic, but it makes the build log noisier than single-process. Consider suppressing the per-worker startup log (the parent already knows the ports via IPC) or only logging it for the in-process server.

@ask-bonk

ask-bonk Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully: #2437 (review)

Summary

Seventh /bigbonk pass on PR #2437 (parallelize prerender across a pool of render processes). I verified all prior findings are resolved and posted a batch review with one new finding plus two minor notes. No correctness blockers.

Confirmed resolved (from the six prior passes):

  • Lowercase VINEXT_PRERENDER_CACHE_LIFE_HEADER constant now matches the index.ts:4573 raw-req.headers strip loop (Node lowercases keys), and the request-pipeline.ts .toLowerCase() workaround is gone.
  • App Router 404 recordRenderError present in both router paths.
  • renderPorts initialized before any rscHandler fetch (no divide-by-zero).
  • closeChild awaits exit with a 5s timeout.
  • Cache-life header round-trip authoritative via responseCacheLife ?? processCacheLife.
  • Ran the three affected test files locally — 160/160 green, so the 5th review's CI blocker is fixed.

New finding (behavior regression risk, non-blocking):

  • startPrerenderServerPool is awaited with no catch inside the main try in both prerenderPages (prerender.ts:851) and prerenderApp (prerender.ts:1576). The pool is a pure performance optimization on top of an already-healthy in-process prodServer that can render everything. If pool startup rejects (transient fork error, ephemeral-port race, or a slow CI box exceeding the 60s readiness timeout), the whole build fails — a build that succeeds today single-process could newly fail. Unlike render-time crashes (which correctly fail loud via assertHealthy() for output correctness), startup failure should degrade to single-process. Posted a suggestion to wrap in try/catch and fall back. The six prior passes didn't surface this — they focused on render-time crash handling, not startup-failure resilience.

Two minor notes:

  • Each forked worker logs [vinext] Production server for prerendering running at ... (up to 8 lines on high-core machines) — cosmetic log noise.

The pool design, crash handling, sizing math, and cache-life round-trip are solid. The single actionable item is the startup-failure fallback.

github run

@james-elicx
james-elicx force-pushed the perf/prerender-parallel branch from b895a2d to 6a4bad1 Compare June 30, 2026 18:17
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Eighth review pass on PR #2437. I independently re-verified the code and confirmed every actionable finding from the seven prior /bigbonk passes is now resolved in the diff:

  • Startup-failure fallback (7th review): startOptionalPrerenderServerPool wraps startPrerenderServerPool in try/catch and degrades to single-process with a warning rather than failing the build (prerender.ts:83-98), wired into both routers (prerender.ts:870, 1595).
  • App Router 404 recordRenderError (3rd review): present at prerender.ts:1650, mirroring the Pages Router path at prerender.ts:965.
  • Lowercase VINEXT_PRERENDER_CACHE_LIFE_HEADER (6th review): now "x-vinext-prerender-cache-life" (headers.ts:62), and the request-pipeline.test.ts assertion explicitly asserts every VINEXT_INTERNAL_HEADERS entry is lowercase (tests/request-pipeline.test.ts:801-803).
  • Cache-life header round-trip (4th/5th review): responseCacheLife ?? processCacheLife (prerender.ts:1465) makes the header authoritative while still draining the process-global; new app-page-render tests assert the header payload (tests/app-page-render.test.ts:1040, 1078).
  • closeChild awaits exit with a timeout (prerender-server-pool.ts:241-257); silent suppresses per-worker startup logs (prod-server.ts).
  • Stale VINEXT_INTERNAL_HEADERS test (5th review): updated at tests/request-pipeline.test.ts:798-800.

I also verified render/resolution split (secret endpoints + static-paths stay on baseUrl, only renders round-robin), worker/in-process NoOpCacheHandler + noCompression: true parity, pool outDir matching the in-process server in both routers, and isPrerender-gated header emission (never on client responses, never persisted into HTML/RSC files).

No correctness blockers. One small fresh note inline confirming a non-issue, for the record.

payload.revalidate = requestCacheLife.revalidate;
}
if (typeof requestCacheLife.expire === "number" && Number.isFinite(requestCacheLife.expire)) {
payload.expire = requestCacheLife.expire;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Confirmed-not-a-bug note (no change needed): the header payload carries only revalidate/expire and drops the stale field that CacheLifeConfig also has. In single-process mode processCacheLife (from _consumeRequestScopedCacheLife()) includes stale, so pooled vs single-process requestCacheLife differ in that field — but resolveRenderedCacheControl (prerender.ts:1677-1690), the only consumer feeding the manifest, reads exactly revalidate and expire and never stale. So the manifest output is identical either way and the byte-identical claim holds for cache metadata too. Flagging only so a future reader who adds stale to the manifest derivation remembers to also add it to this side-channel payload.

@ask-bonk

ask-bonk Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully: #2437 (review)

Summary

Eighth /bigbonk pass on PR #2437 (parallelize prerender across a pool of render processes). I independently re-verified the implementation and confirmed all seven prior passes' actionable findings are now resolved in the current diff. No correctness blockers.

Confirmed resolved:

  • Startup-failure fallback (startOptionalPrerenderServerPool degrades to single-process on pool-start error instead of failing the build — the 7th review's main finding).
  • App Router 404 recordRenderError mirrors the Pages Router path.
  • Lowercase VINEXT_PRERENDER_CACHE_LIFE_HEADER + a test asserting every VINEXT_INTERNAL_HEADERS entry is lowercase.
  • Cache-life header round-trip authoritative via responseCacheLife ?? processCacheLife, with new tests asserting the payload.
  • closeChild awaits exit with a timeout; silent suppresses per-worker startup logs.
  • The stale VINEXT_INTERNAL_HEADERS toEqual assertion (5th review's CI blocker) is updated.

Independently re-verified solid: render/resolution split (only renders round-robin, secret endpoints stay on baseUrl), worker/in-process NoOpCacheHandler + noCompression: true parity, pool outDir matching the in-process server in both routers, and isPrerender-gated header emission (never on client responses, never persisted into output files).

One fresh note (non-issue, documented for the record): the cache-life side-channel header drops the stale field that CacheLifeConfig carries, so pooled vs single-process requestCacheLife differ in stale — but the only manifest consumer (resolveRenderedCacheControl) reads exactly revalidate/expire, so output stays byte-identical. Flagged only so a future reader who adds stale to the manifest derivation remembers to also add it to the payload.

github run

@james-elicx
james-elicx merged commit 13d3637 into cloudflare:main Jun 30, 2026
52 checks passed
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.

2 participants