Shopify / Shopify/hydrogen

dev server returns `500 Internal Server Error` on concurrent requests

Open
#3,993 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
2.1k
Forks
443
Avg merge
4d 19h
Merged PRs (30d)
24

Description

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
  1. Launch the mini-oxygen dev server: npm run dev
  2. 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Reproduce the failure with the concurrent curl command, then inspect @shopify/mini-oxygen/dist/vite/worker-entry.js, especially cachedModule and the module-scope runtime. Compare the proposed approaches with the linked Cloudflare workers-sdk change. Done means concurrent dev-server requests consistently return 200 without the never-generate-a-response error.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript, vite
Domain
backend, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.