Cache Components dev validation reported stack frames that pointed at
build output whenever a module had been updated while the dev server
ran. This affected both the static shell validation and the
instant-navigation validation, since both run on the same worker. The
overlay showed a raw `file:` URL and the terminal named the chunk rather
than the page, and because the frame never resolved to a source position
there was no code frame either, so nothing indicated which line caused
the error.
Turbopack's server HMR evaluates an updated module as a script of its
own, named `<chunk>?<module id>` and carrying its source map inline
rather than on disk, so only the isolate that ran that `eval` can
resolve a frame in it. The validation worker never ran it, and the map
beside the chunk describes the chunk's lines, not the running module's,
so nothing the worker could reach described the frame. React then wrote
the frame in its form for scripts without a source map, which encodes an
already-encoded URL a second time, leaving a frame no reader reverses.
The worker now mirrors what the dev server does to its own module state
rather than being dropped whenever that state changes. The dev server
reports each applied update, the manifest cache entries it cleared, and
the paths it evicted, and the worker replays them in the same order, so
its module state is the dev server's module state by construction. That
leaves each updated module's inline source map in the worker's own
Node.js cache, which is what makes the frame resolvable there.
The worker needs no coordination around a validation in flight. It runs
one call at a time, in the order the calls were made, so an update is
replayed before any validation requested after it, and never in the
middle of one. The dev server does not hold its own updates back for a
validation running in process either. Where it gives up and re-evaluates
every module from disk the worker is dropped, so that case keeps the
behaviour it had.
Not dropping the worker helps beyond the frames. Dropping it meant the
next validation had to spawn a worker thread and run `loadComponents`
again before it could start, and it paid that on every edit, which
delayed the insight at exactly the moment the user is waiting for it.
The case in the test suite that covers this went from around 870ms to
around 240ms.
The simpler fix was to revive the transported errors on the main thread
and print them there, where the scripts already are. It works, and it is
why this PR also touches the benchmark: the fixture produced no
validation errors, so nothing in the benchmark reached the error
reporting at all, and the cost of moving it was invisible. With insights
generated, the cost showed plainly. Printing an error costs around 218ms
the first time a source map is read and about a millisecond after that,
and moving it to the main thread cut the worker's p95 advantage on the
heaviest route from around 15ms to between 2ms and 5ms. Mirroring the
updates keeps the printing on the worker and leaves that advantage
intact.
The three commits are worth reading in order. The first adds the test
with the broken output snapshotted, so its snapshots deliberately record
what a user saw, a frame naming the chunk with no code frame beneath it.
The second is the benchmark change above. The third is the fix, and its
diff turns those snapshots into resolved frames, adds cases that edit
the same module twice, edit a module the page imports, and validate a
route that another route's update did not touch, and rewrites the
suite's header comment, which described the mechanism this replaces.
Verified on both bundlers, since the worker is gated on Turbopack and
Webpack validates in process, along with
`instant-validation-scheduling`,
`instant-validation/{server-errors,parallel-slots}`,
`instant-validation-causes`, `instant-validation-level-default` and
`hmr-rsc-cancellation`. Run with `BENCH_DEV_VALIDATION_INSIGHTS=1`, the
benchmark shows no steady-state regression: the worker column matches
canary at 106ms sprite p95 against 110ms and 109ms, and keeps its margin
over in-process.
Two things are deliberately left out. The benchmark still cannot measure
the edit case, because it never edits, so the timing above comes from a
test's wall clock rather than a purpose-built measurement. And
`use-cache-probe-pool` subscribes to the same invalidation and tears
down the same way, which is the obvious follow-up if this holds up.
One known gap remains. A worker dropped by its own failure, rather than
by the dev server giving up, cannot obtain the scripts the dev server
evaluated from earlier updates, so frames naming them stay unresolved
until those modules change again. The validation itself is unaffected,
because the worker loads the current code from disk.
4.8 KiB
Dev-validation benchmark
Measures how much dev-mode Cache Components validation contends for the dev
server's event loop during rapid navigation, and how much running it on a worker
thread (experimental.devValidationWorker, default on) relieves that.
In next dev with Cache Components, every navigation runs a staged validation
render (this covers static-shell validation, which runs on initial load and HMR
refresh, as well as instant-navigation validation when instant is configured).
These renders run on the dev server's event loop. When you navigate rapidly (the
canonical "click the same nav item over and over" case), the validation from
earlier navigations piles up and starves the loop, so later requests wait behind
it. This benchmark reproduces that and reports the browser-observed impact.
Running
pnpm bench:dev-validation
By default it runs an A/B on the same build: validation on a worker thread (the
default) versus in-process (experimental.devValidationWorker: false), and
prints the speedup. Options:
--worker=true|false— run a single configuration instead of the A/B.--bundler=turbopack|webpack— defaultturbopack.--clicks=<n>— measured navigations per family (default 48).--port=<n>,--headless=false,--settle-ms=<n>.--json-out=<path>— write the raw stats as JSON.
Set BENCH_DEV_VALIDATION_INSIGHTS=1 to give every family's leaf page an
uncached data access, so validation reports one insight per navigation. Without
it the run only covers the validation renders; with it, it also covers what
follows an insight — encoding the errors and printing them with a source-mapped
stack and code frame — which is the part that moves when that printing changes
threads. The access sits below the family's heavy subtree, so validation still
does that work before it reaches it: a page that suspends before it returns
leaves the subtree unrendered in the validation pass. The routes become
dynamic, so the absolute numbers are not comparable with a run without the
flag; the worker-versus-in-process comparison within a run is.
This depends on experimental.devValidationWorker existing, so it stacks on the
PR that adds the flag. Until the worker implementation lands, both
configurations run in-process and the A/B shows no delta; once it lands, the
worker column drops.
The fixture's heavy routes are generated (and gitignored); the runner
regenerates them before booting. To generate by hand: node scripts/generate.mjs.
What it measures
For each route family the runner clicks the family's <Link> repeatedly.
Navigating to the current route re-renders and re-validates it, so every click
triggers a fresh validation. The routes carry no instant config; dev
validation applies to page segments by default at the warning level.
Each family's route is nested several layout segments deep (under a (routes)
route group that keeps the URL clean). Validation renders a combined payload at
every URL depth, so a deeper route means more validation renders per navigation.
This mirrors a realistically deep app rather than a single flat segment; a flat
route barely exercises the depth loop. The three families isolate different
per-render costs:
- client — the leaf is a large tree of distinct
use clientcomponents. Stresses validation's client prerender (react-dom/static). - server — the same recursive tree, but server components. Stresses the
Flight re-encode plus the React owner-stack /
createTaskwork validation re-processes per depth, which scales with component count. - sprite — one very large SVG server component (many
<symbol>s), like a shared icon sprite, rendered in the family's shared layout so it is part of the payload at every depth. Stresses Flight payload size rather than component count.
Primary signal: browser-observed TTFB. The runner reads Playwright's own
network timing (request.timing()) for each navigation and reports TTFB
(responseStart - requestStart), the time the browser waits for the server.
That wait includes the event-loop queue time while validation monopolizes the
loop, which is exactly the contention we care about.
The CLI's logged request durations (GET … in Xms (…, application-code: Yms))
are not used as the signal. The dev server starts that clock inside the
request handler, after the event loop has already yielded to the request, so the
time a request spends queued behind validation is invisible to it.
Interpreting results
Running validation on a worker thread frees the event loop between navigations, so TTFB drops and, more importantly, the long tail (the multi-second stalls where the loop is fully starved) disappears. The sprite family shows the largest per-render cost. Absolute numbers vary by machine; run the A/B on one machine and compare the two columns.