Vitest architecture note

One module graph snapshot instead of one RPC per dependency

PR 10708 lets a worker ask the main process once for already-transformed modules, then read their cached code directly from disk. The normal fetch pipeline remains the fallback.

Commit 98bc513e7 Branch perf/warm-modules PR 10708 Pinned commit
Contents

The cost being removed

Vite's evaluator discovers dependencies sequentially. Previously, even an already-transformed module required a main-process fetch RPC. The snapshot moves only the lookup and file transfer off that repeated path.

Worker runtime Process RPC Main/Vite server Disk cache Fallback path

Before: serialized round trips

Every dependency asks the main process independently.

N modules = up to N fetch RPCs
Cold dep→rpc.fetch(dep1.ts)
Warm dep→rpc.fetch(dep2.ts)
Warm dep→rpc.fetch(dep3.ts)

After: snapshot, then local reads

One map returns cache paths for the known graph.

N warm modules = 1 snapshot RPC
Snapshot→rpc.fetchWarmModules(files)
Cold dep1→fall through to rpc.fetch
Warm dep2→readFileSync(dep2 path)
Warm dep3→readFileSync(dep3 path)

Happy path and graph-boundary miss

Direct imports are already present in second.test.ts.importedModules after entry transform. That lets a snapshot triggered by one cold dependency collect its warm siblings. The miss case appears when a warm module sits behind a cold, untransformed intermediary.

Happy: warm direct siblings

Direct-import graph

1. Existing server state
first.test.tsalready ran
→
dep2.tswarm
dep3.tswarm
then a fresh entry starts
2. Current entry after import analysis
second.test.tsentry fetched now
→
dep1.tscold trigger
dep2.tswarm sibling
dep3.tswarm sibling
Snapshot result

What happens on dep1.ts

  1. Fetch second.test.ts normally. Vite import analysis creates direct graph edges to dep1.ts, dep2.ts, and dep3.ts.
  2. Start fetching cold dep1.ts. This first dependency call triggers fetchWarmModules.
  3. Walk every direct edge. The graph walker sees all three dependency nodes even though dep1.ts happened to trigger the call.
  4. Return the warm subset. The snapshot includes second.test.ts, dep2.ts, and dep3.ts; cold dep1.ts is omitted.
  5. Mix fallback and local reads. dep1.ts uses normal RPC, while later requests for dep2.ts and dep3.ts read their snapshot paths locally.
Miss: warm module behind cold intermediary

The transitive edge is not known yet

Contrast the direct-import case with two chains whose unique intermediary differs:

first.test.ts
→
first-dep.tswarm
→
shared-dep.tswarm
second.test.ts
→
second-dep.tscold
→
shared-dep.tswarm but hidden

second.test.ts import analysis can expose only its direct second-dep.ts edge. Because that node has never been transformed, its importedModules does not yet reveal shared-dep.ts. The first snapshot therefore contains the entry only; both dependencies fall back in this context.

What crosses the RPC boundary

Conceptually, the worker sends the scope of the current run. The server returns a lookup table for only the already-warm parts of that scope. This is the relevant new information, not the exact serialized TypeScript shape.

Worker → main: snapshot request
fetchWarmModules(
  "node",
  [
    "/project/test/second.test.ts"
  ]
)

These are the RPC's two positional arguments: environment name and current run entry paths. The server appends configured setup-file paths before walking the graph.

Main → worker: warm lookup table
{
  // The just-fetched entry is part of the walk.
  "/test/second.test.ts": {
    cached: true,
    tmp: "/tmp/vitest/.../a1b2",
    file: "/project/test/second.test.ts",
    id: "/project/test/second.test.ts",
    url: "/test/second.test.ts",
    invalidate: false
  },

  // Alias with the same descriptor values
  "/project/test/second.test.ts": { ... },

  "/src/dep2.ts": {
    cached: true,
    tmp: "/tmp/vitest/.../d2",
    id: "/project/src/dep2.ts",
    url: "/src/dep2.ts",
    ...
  },

  "/src/dep3.ts": {
    cached: true,
    tmp: "/tmp/vitest/.../d3",
    id: "/project/src/dep3.ts",
    url: "/src/dep3.ts",
    ...
  },

  // dep1.ts is absent: its node is cold.

  "/node_modules/pkg/index.js": {
    externalize: "file:///.../pkg/index.js",
    type: "module"
  }
}

This is the happy direct-import snapshot. The triggering dep1.ts is absent, but its warm sibling nodes are already visible through second.test.ts.importedModules.

not transformed invalidated no disk path
The worker stores this as a run-scoped promise Dependency fetches look up both the requested module ID and its normalized raw ID. A disk descriptor produces a local file read; an external descriptor is returned directly; no entry means fall back to the original per-module RPC.
Review concern: external results need environment scope The PR stores externalization results by Vite server and requested URL, then shares them with every environment. Vite resolves even leading-slash URLs through each environment's plugin container, so the same URL can map to different resolved IDs. The external cache should also be keyed by environment.

The important contrast: reuse is worker-local

Keeping an already-evaluated module is still the cheapest path, but that reuse belongs to one worker runtime. With multiple non-isolated workers, each runtime has its own evaluated-module cache and must perform an initial fetch; snapshots can help one worker reuse server work performed by another.

Best reuse path

isolate: false

The same worker keeps its evaluated module cache across test files. A shared dependency such as shared-dep.ts is reused as a live module.

  • Per-module RPCskipped
  • Disk readskipped
  • Parse / compileskipped
  • Module evaluationskipped
Scope: one runtime. A second non-isolated worker does not inherit this live module instance. Its first request can still use a warm snapshot produced from the shared server graph.
Improved isolated path

isolate: true + warm snapshot

The fresh worker-side module cache must instantiate shared-dep.ts again. The PR makes obtaining its transformed source cheaper.

  • Per-module RPCbatched away
  • Disk read
  • Parse / compile
  • Module evaluation
Bottom line isolate: false remains the larger optimization for repeated imports inside the same runtime. It is not snapshot-free: with multiple workers, every runtime has a first-load boundary, so one worker can use the same server-warm snapshot path after another worker has populated the graph. isolate: true creates that boundary much more often.

Run-to-module call chain

The key timing decision is step 3: the entry uses the old path first. Only the first dependency requests the snapshot, after entry transform and import analysis have connected the graph.

Worker
RPC boundary
Main / Vite
Disk
1. Run context arrivesinit → execute → runTests
Contains files, environment, and RPC client.
·
·
·
2. Module transport startsstartVitestModuleRunner
Installs transport.fetchModule.
·
·
·
3. Entry fetchimporter == null
Warm lookup is intentionally skipped.
Existing requestrpc.fetch(entry)
Transform + import analysisVite populates or reconnects the entry's module graph.
Optional cache writeFork temp copy or filesystem module cache.
The graph must exist before it can be snapshotted.
New in PR4. First dependencyimporter != null
Calls the memoized helper.
New in PROne request per ctxfetchWarmModules(environment, files)
New in PRWalk live graphSeed from test + setup files; follow importedModules.
New in PRReturn locationsOnly nodes with a current transform and disk path qualify.
New in PR5. Warm hitMatch requested id or unwrapped rawId.
·
·
New in PRLocal source readreadFileSync(warmResult.tmp)
No per-module RPC.
6. Miss or vanished fileAbsent snapshot entry or failed disk read.
Fallback RPCrpc.fetch(id, importer, ...)
Normal fetch pipelineResolve, externalize, transform, or return cache result.
Cache may be createdA later run can include it in a fresh snapshot.

Three lifecycle rules

These rules make the optimization incremental and preserve the existing module-loader behavior.

1

Delay until a dependency

The entry has no importer and follows the normal fetch path. Its transform connects the graph before the snapshot is requested.

2

Memoize per run context

The worker shares one snapshot promise while workerState.ctx is unchanged. If that runtime receives another run request, its new context requests another snapshot.

3

Fallback on every miss

A missing graph node, missing disk path, or deleted temporary file uses the original rpc.fetch path.

Invalidation boundary Invalidated server nodes lose their current transformResult, so the next graph walk omits them. The worker then fetches those modules live instead of trusting an old disk path.

Patch anatomy

This is the single implementation reference. File names link to the reviewed commit, while bar lengths encode additions. The warm-module mechanism is concentrated in the first two rows; gray rows belong to the separate Node compile-cache optimization.

node/pools/rpc.tsMain-side graph walk and external snapshots
+86
runtime/moduleRunner/startVitestModuleRunner.tsWorker memoization, lookup, disk read, fallback
+56 / -1
vitest.mjsSeparate Node compile-cache optimization
+18 / -1
node/environments/fetchModule.tsRemember fresh filesystem-cache path
+8 / -1
node/pool.tsDisable compile cache for coverage workers
+8
types/rpc.tsDeclare the new RPC contract
+6
Main/server implementation Worker implementation RPC contract Compile-cache change