cloudflare / cloudflare/workers-sdk
[vitest-pool-workers]: the module fallback service makes one sequential HTTP round-trip per `node_modules` import — has pre-injecting resolved modules been considered?
- Dominant language
- TypeScript
- Stars
- 4.5k
- Forks
- 1.5k
- Avg merge
- 3d 8h
- Merged PRs (30d)
- 187
Description
## Summary
Under `@cloudflare/vitest-pool-workers`, every `node_modules` module that `workerd`
can't resolve natively is fetched over HTTP from the Node-side **Module Fallback
Service**, one module per round-trip. Because `workerd` discovers a module's
dependencies only _after_ it has compiled that module, these round-trips are
**inherently sequential**. For a test worker with a CJS-heavy transitive dependency
tree, this scales as _O(number of modules)_ sequential HTTP round-trips before the
test body even runs — which is structurally slow regardless of how fast each
individual round-trip is.
This is effectively the **unfinished half** of cloudflare/workers-sdk#5395
(_"Improve Workers Vitest integration performance"_). That issue named two levers for
the fallback-service cost: the **number of requests** (~800 at the time) and the
**per-request cost** ("each HTTP request starts a new OS thread and opens a new TCP
connection… use a thread pool with connection reuse"). cloudflare/workerd#6115
(_"reuse HTTP connections in fallback service to prevent port exhaustion"_) implemented
the connection-reuse half — it stops port exhaustion (`Fixes` cloudflare/workers-sdk#7954)
and reuses connections — and #5395 was then closed with the maintainer note that it was
_"in large part addressed by #6115."_
The half that remains is the **number of sequential round-trips itself**. Connection
reuse lowers per-request transport overhead but does not reduce how many requests are
made, nor that they are issued one after another. For CJS-heavy dependency trees that
count is the structural bottleneck.
This issue argues from the mechanism rather than from a specific benchmark, and asks
whether pre-injecting resolved dependencies into `workerd`'s module graph — addressing
the request-count lever directly — is a direction the team would consider.
## Environment
| | |
| --------------------------------- | ----------------------------------------------------------- |
| `@cloudflare/vitest-pool-workers` | `0.16.4` |
| `vitest` | `4.1.6` |
| `wrangler` | `4.93.0` |
| `miniflare` | `4.20260508.0` (transitively pinned by vitest-pool-workers) |
| `workerd` | `1.20260508.1` (transitively pinned via miniflare) |
| Node | `20` |
| OS | Linux (x64 / arm64) |
## The mechanism
When `workerd` encounters an `import`/`require` it can't resolve from its own module
registry, it issues an HTTP request to the fallback service that the pool runs on the
Node side; Node (via Vite) resolves the specifier, reads the file, and returns the
module source. Two properties of this make CJS-heavy trees expensive:
1. **One round-trip per module.** Each unresolved specifier — including every internal
relative `require` inside a package — is its own request/response.
2. **The round-trips are sequential, not batchable.** `workerd` walks the module graph
lazily: it only learns that a module needs `require('./internal-helper')` _after_ it
has fetched and compiled the parent. So there is no complete list of "everything this
worker needs" to fetch up front — modules are discovered, and therefore fetched, one
at a time in dependency order.
```
test file → import("some-cjs-pkg") → workerd can't resolve → HTTP → Node/Vite resolves + reads → HTTP back
→ that module does require("./internal") → workerd can't resolve → HTTP → ... → repeats per module, sequentially
```
A single CJS package can fan out to hundreds of these requests on its own — e.g.
`lodash`'s entry point pulls in a large tree of internal files, each via its own
relative `require`, each a separate sequential round-trip.
The consequence: the dominant cost of a vitest-pool-workers run on a CJS-heavy project
is **module loading over the fallback channel**, not the test logic itself. The total
is essentially `(number of modules) × (per-round-trip latency)`, serialized.
## Why caching / connection-level fixes don't address it
We explored the obvious mitigations; sharing the lessons so they can be skipped:
- **Connection reuse** (cloudflare/workerd#6115) removes transport/port-exhaustion
overhead but leaves the _count_ of sequential round-trips unchanged — the requests
still happen one after another.
- **Response/resolution caching** keyed on `(method, specifier, referrer)` helps little
within a single run, because modules are requested from largely unique
`(specifier, referrer)` pairs, so intra-run hit rate is low.
- **Pre-bundling each package into a single ESM module** removes a package's _internal_
round-trips, but in our exploration it traded one problem for another: bundling pulls
Node-builtin/polyfill bloat into otherwise-tiny packages and can regress them badly,
and a fallback-side intercept only catches top-level specifiers (sub-path/internal
requires still miss). It was not a net win.
The common thread: the bottleneck is the **volume of sequential round-trips**, so the
effective fix is to **remove the round-trips**, not to make them cheaper or fewer-by-
bundling.
## Proposed direction (open to alternatives)
**Pre-inject the resolved `node_modules` modules into `workerd`'s module graph before
tests run** — register them in Miniflare's `modules` config so `workerd` resolves them
natively and the fallback service is never consulted for `node_modules`.
We'd suggest injecting the **individually-resolved files as-is (no bundling)**:
- It does the same resolution + read work the fallback service already does, just
in-process and up front, and strips out only the sequential HTTP layer.
- It avoids the bundling regressions noted above and preserves import-execution order /
side-effect semantics exactly.
- It can reuse `workerd`'s existing CJS handling by injecting each file with its correct
module type — no transpilation needed.
The natural place looks like wherever the pool assembles the Miniflare worker's
`modules` array, extending it with the pre-resolved dependency set.
### Open questions for maintainers
- **Does `workerd` parse/compile the `modules` array eagerly at startup, or lazily on
first import?** This determines whether injecting the full dependency set (including
unused files) is free or costly, and thus whether injection should be scoped to the
statically-reachable import graph.
- Run at config-load time (transparent, adds startup latency) vs. as a separate build
step (cleaner, more workflow)?
- Invalidation when `node_modules` changes (content hash / version check).
- Sub-path exports (`pkg/sub`) — enumerating entry points from `package.json` `exports`.
- Is there a reason the current design prefers on-demand fallback over pre-injection that
we're not seeing (e.g. correctness around dynamic `require`, conditional exports)?
## Impact
This affects any test suite with a CJS-heavy transitive dependency tree: module loading
over the fallback channel becomes the dominant cost of the run, growing with dependency
count rather than with the amount of code under test.
## References
- cloudflare/workers-sdk#5395 — _Improve Workers Vitest integration performance_. Identified both the request count (~800) and per-request cost as levers; closed 2026-03-18 with the note that it was _"in large part addressed by #6115."_ This issue is the remaining request-count half.
- cloudflare/workerd#6115 — _reuse HTTP connections in fallback service to prevent port exhaustion_ (merged 2026-02-26). Implemented connection reuse and fixed port exhaustion (`Fixes` cloudflare/workers-sdk#7954); did not reduce the round-trip count.
- cloudflare/workers-sdk#7954 — _Error running tests with `singleWorker: false`_ (port-exhaustion crash; fixed by #6115).
- Vite dependency pre-bundling (`node_modules/.vite/deps/`) — already performs CJS→ESM conversion and tree-shaking; a possible source of pre-resolved modules, though it targets browser/SSR conditions rather than `workerd`.
Contributor guide
Research direction
No specific file or test is named. Start by tracing where @cloudflare/vitest-pool-workers assembles the Miniflare worker's modules array and read the existing fallback-service and modules configuration. Done means determining whether pre-injecting resolved node_modules files is correct and practical, with its startup, invalidation, export, and dynamic-require implications documented.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, typescript, vite
- Domain
- developer-experience, performance, testing
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100