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?

Open
#14,283 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.