What is the location of your example repository?
No response
Which package or tool is having this issue?
Oxygen
What version of that package or tool are you using?
mini-oxygen 4.2.2
What version of React Router 7 are you using?
No response
Steps to Reproduce
- Launch the mini-oxygen dev server:
npm run dev
- Launch 8 parallel/concurrent fetch of any endpoint, for example
/robots.txt
for i in $(seq 1 8); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/robots.txt & done; wait
Expected Behavior
All parallel requests return 200
Actual Behavior
Many parallel requests return 500, with the body:
Error: The script will never generate a response.
at async Object.fetch (.../node_modules/miniflare/dist/src/workers/core/entry.worker.js:1029:22)
Cause
Vite's ModuleRunner de-duplicates concurrent module resolution by caching the in-flight promise, in @shopify/mini-oxygen/dist/vite/worker-entry.js:
async cachedModule(url, importer) {
let cached = this.concurrentModuleNodePromises.get(url);
if (cached) { /* reuse it */ }
else {
cached = this.getModuleInformation(url, importer, cachedModule)
.finally(() => { this.concurrentModuleNodePromises.delete(url); });
this.concurrentModuleNodePromises.set(url, cached);
}
return cached;
}
The runner is a module-scope singleton (var runtime in the same file), so that Map is shared by every request in the isolate. Request A starts getModuleInformation, whose I/O belongs to A's invocation, and parks the pending promise in the Map. Request B arrives while it is pending, takes A's promise and awaits it, and the Workers runtime forbids sharing I/O across invocations, so B's promise is severed, its handler never settles, and the entry worker's await service.fetch(request) rejects with the message above.
Upstream
vitejs/vite#20283 "ModuleRunner - Sharing concurrentModuleNodePromises across invocations is not compatible with Cloudflare Workers runtime".
Closed as p3-downstream-blocker with no fix in Vite, so the cache is still there in the runner mini-oxygen bundles.
Related: cloudflare/workers-sdk#9518.
Cloudflare fixed it on their side in cloudflare/workers-sdk#12953 (merged March 2026), touching one file: packages/vite-plugin-cloudflare/src/workers/runner-worker/module-runner.ts. They had a CustomModuleRunner subclass overriding cachedModule() with exactly this promise cache; the PR deletes the subclass and the cache, and instead intercepts ssrDynamicImportKey at evaluation time so every dynamic import runs through a Durable Object's IoContext (runInRunnerObject). That keeps de-duplication safe by giving the module I/O one long-lived owning context, rather than borrowing whichever request happened to arrive first, and it uses only public Vite APIs.
Patches
I used Claude Code to generate two possible patches, take them as prototypes, they both fixed the issue in two quite different ways.
@shopify+mini-oxygen+4.2.2.patch This patch neutralize the map so it always misses; each invocation then resolves in its own I/O context. Cost is one extra transport round-trip when two requests genuinely race on the same module, and nothing otherwise. Simple but it does reach into a field Vite marks private.
@shopify+mini-oxygen+4.2.2.public-api.patch This patch implements the same approach as Cloudflare: a Durable Object has one I/O context for every event it receives, so promises
created inside it are freely shareable. This keeps Vite's de-duplication rather than defeating it: concurrent requests still share one resolution, and it is now legally owned. More complete, but it brings durable objects and rpc into dev runtime.
What is the location of your example repository?
No response
Which package or tool is having this issue?
Oxygen
What version of that package or tool are you using?
mini-oxygen 4.2.2
What version of React Router 7 are you using?
No response
Steps to Reproduce
npm run dev/robots.txtExpected Behavior
All parallel requests return 200
Actual Behavior
Many parallel requests return 500, with the body:
Cause
Vite's
ModuleRunnerde-duplicates concurrent module resolution by caching the in-flight promise, in@shopify/mini-oxygen/dist/vite/worker-entry.js:The runner is a module-scope singleton (
var runtimein the same file), so that Map is shared by every request in the isolate. Request A startsgetModuleInformation, whose I/O belongs to A's invocation, and parks the pending promise in the Map. Request B arrives while it is pending, takes A's promise and awaits it, and the Workers runtime forbids sharing I/O across invocations, so B's promise is severed, its handler never settles, and the entry worker'sawait service.fetch(request)rejects with the message above.Upstream
vitejs/vite#20283 "
ModuleRunner- SharingconcurrentModuleNodePromisesacross invocations is not compatible with Cloudflare Workers runtime".Closed as
p3-downstream-blockerwith no fix in Vite, so the cache is still there in the runner mini-oxygen bundles.Related: cloudflare/workers-sdk#9518.
Cloudflare fixed it on their side in cloudflare/workers-sdk#12953 (merged March 2026), touching one file:
packages/vite-plugin-cloudflare/src/workers/runner-worker/module-runner.ts. They had aCustomModuleRunnersubclass overridingcachedModule()with exactly this promise cache; the PR deletes the subclass and the cache, and instead interceptsssrDynamicImportKeyat evaluation time so every dynamic import runs through a Durable Object's IoContext (runInRunnerObject). That keeps de-duplication safe by giving the module I/O one long-lived owning context, rather than borrowing whichever request happened to arrive first, and it uses only public Vite APIs.Patches
I used Claude Code to generate two possible patches, take them as prototypes, they both fixed the issue in two quite different ways.
@shopify+mini-oxygen+4.2.2.patch This patch neutralize the map so it always misses; each invocation then resolves in its own I/O context. Cost is one extra transport round-trip when two requests genuinely race on the same module, and nothing otherwise. Simple but it does reach into a field Vite marks
private.@shopify+mini-oxygen+4.2.2.public-api.patch This patch implements the same approach as Cloudflare: a Durable Object has one I/O context for every event it receives, so promises
created inside it are freely shareable. This keeps Vite's de-duplication rather than defeating it: concurrent requests still share one resolution, and it is now legally owned. More complete, but it brings durable objects and rpc into dev runtime.