#4638·nitro

vite dev: `createServiceEnvironment` registers `ssr` in `_viteEnvs` before building it, so the dev worker's first `getBuiltins` invoke is dropped and SSR wedges permanently

Author: csulitCreated Sep 17, 2026Updated Sep 17, 2026

Environment

  • nitro 3.0.260610-beta (also present in 3.0.260903-beta and on main)
  • vite 8.3.0, env-runner 0.1.16, NITRO_DEV_RUNNER unset (node-worker)
  • Node v24.1.0, pnpm 10.28.2, macOS (darwin arm64)
  • TanStack Start app (the ssr service environment is TanStack Start's)

Describe the bug

On most vite dev starts the SSR environment never comes up and every request returns 500 for the life of the process:

NitroViteError: Vite environment "ssr" is unavailable
    at ViteEnvRunner.fetch (nitro/dist/runtime/internal/vite/dev-worker.mjs)

and, 60s into the run:

transport invoke timed out after 60000ms
  (data: {"type":"custom","event":"vite:invoke",
          "data":{"name":"getBuiltins","id":"send:IAvQac_CZj4IB62ayHqXK","data":[]}})
    at reviveInvokeError (vite/dist/node/module-runner.js:540:14)
    at ModuleRunner.import (vite/dist/node/module-runner.js:1131:23)
    at ViteEnvRunner.reload (nitro/dist/runtime/internal/vite/dev-worker.mjs:58:20)

It is not the cold-start 503 fixed for #4295 / #4405 — that one recovers. Here the very first module-runner invoke is dropped, so the SSR entry never finishes importing, entryError is set, and the process never recovers. It still happens on 3.0.260903-beta (whose longer reload wait just turns the first 30s into did not finish reloading within 30000ms before the same permanent failure).

Cause

createServiceEnvironment registers the environment in ctx._viteEnvs before awaiting its construction, while createNitroEnvironment registers it after. On main, src/build/vite/env.ts:

typescript
// createNitroEnvironment — correct order
createEnvironment: async (envName, envConfig) => {
  const entry = resolve(runtimeDir, "internal/vite/dev-entry.mjs");
  const { createFetchableDevEnvironment } = await import("./dev.ts");
  const env = await createFetchableDevEnvironment(envName, envConfig, await initEnvRunner(ctx), entry, {...});
  ctx._transformRequest = (id) => env.transformRequest(id);
  (ctx._viteEnvs ??= new Map()).set(envName, entry);   // <-- last
  return env;
},

// createServiceEnvironment — L96-106
createEnvironment: async (envName, envConfig) => {
  const entry = tryResolve(serviceConfig.entry);
  (ctx._viteEnvs ??= new Map()).set(envName, entry);   // <-- first
  const { createFetchableDevEnvironment } = await import("./dev.ts");
  return createFetchableDevEnvironment(envName, envConfig, await initEnvRunner(ctx), entry, {...});
},

initEnvRunner installs manager.onReady(...), which replays every entry of ctx._viteEnvs to the dev worker as a nitro:vite-env message. So if the runner becomes ready during the two awaits above, the worker is told about ssr and builds its ViteEnvRunner — whose constructor immediately calls reload()runner.import()transport.invoke("getBuiltins") — while the main thread has not yet constructed the ssr FetchableDevEnvironment and so has no vite:invoke listener for viteEnv: "ssr" on the hot channel. The worker's TRANSPORT.send lands in RunnerManager._messageListeners with nothing matching, is silently dropped, and getBuiltins times out 60s later.

Logging every send, on, match and non-match in env-runner/dist/vite.mjs shows it directly. A failing run, verbatim from lines 5-15 of the dev server's output — MAIN/WORK is worker_threads.isMainThread, HOTCH is createViteHotChannel, TRANSPORT is createViteTransport, and HOTCH.MISS is a message reaching a hot channel listener whose viteEnv does not match:

[ER:WORK] createViteTransport ssr
[ER:WORK] TRANSPORT.connect ssr
[ER:WORK] TRANSPORT.send ssr custom/vite:invoke/getBuiltins#send:7EygdUvzCeLFqpT7hHiuy viteEnv=undefined
[ER:MAIN] createViteHotChannel ssr          <-- the ssr env is constructed here, after the send
[ER:MAIN] HOTCH.on ssr vite:invoke          <-- its listener registers two lines late
[ER:MAIN] HOTCH.on ssr vite:invalidate
[ER:MAIN] createViteHotChannel nitro
[ER:MAIN] HOTCH.on nitro vite:invoke
[ER:MAIN] HOTCH.on nitro vite:invalidate
[ER:WORK] createViteTransport nitro
[ER:MAIN] HOTCH.MISS ssr wanted vite:invoke got custom/vite:invoke/getBuiltins#send:K13t7sdjbYPmQqoaZfTw7 viteEnv=nitro

send:7EygdUvzCeLFqpT7hHiuy appears on no [ER:MAIN] line anywhere in the run — not as a match, not even as a MISS. That absence is the evidence: at the moment the worker sent it there was no hot channel listener for any environment, so nothing on the main thread could observe it at all. The only other place that id reappears is the timeout 60s later: transport invoke timed out after 60000ms (data: {"type":"custom","event":"vite:invoke","data":{"name":"getBuiltins","id":"send:7EygdUvzCeLFqpT7hHiuy","data":[]}}). (viteEnv=undefined on the send line is an artefact of the logger printing the payload before createViteTransport.send spreads viteEnv onto it; the message itself carries viteEnv: "ssr".)

The nitro environment in the same run is fine, because its _viteEnvs.set comes last, so the worker only ever hears about it after the listener exists — its getBuiltins is matched immediately:

[ER:WORK] TRANSPORT.send nitro custom/vite:invoke/getBuiltins#send:K13t7sdjbYPmQqoaZfTw7 viteEnv=undefined
[ER:MAIN] HOTCH.match nitro custom/vite:invoke/getBuiltins#send:K13t7sdjbYPmQqoaZfTw7 viteEnv=nitro
[ER:WORK] TRANSPORT.recv nitro custom/vite:invoke/getBuiltins#response:K13t7sdjbYPmQqoaZfTw7 viteEnv=nitro

And with the reorder below applied, the same logging shows the ordering flip and the ssr invoke matching:

[ER:MAIN] createViteHotChannel ssr
[ER:MAIN] HOTCH.on ssr vite:invoke
[ER:WORK] createViteTransport ssr
[ER:MAIN] HOTCH.match ssr custom/vite:invoke/getBuiltins#send:rk2tXJQKe6QZ1DHTRcznh viteEnv=ssr

Reproduction

A pristine @tanstack/cli scaffold with zero application code reproduces it: run pnpm dev and curl localhost:3000/. The first start after a cold node_modules tends to succeed and subsequent starts fail, which fits the window being timing-dependent — but the ordering above is the whole bug, and whether it is hit is machine- and cache-dependent, not app-dependent. Removing .tanstack, .nitro and node_modules/.vite changes nothing.

Fix

Moving the set after the environment is constructed, matching createNitroEnvironment, closes the window — 4/4 consecutive pnpm dev starts serve SSR with no dropped invoke, against 0/2 before:

typescript
createEnvironment: async (envName, envConfig) => {
  const entry = tryResolve(serviceConfig.entry);
  const { createFetchableDevEnvironment } = await import("./dev.ts");
  const env = await createFetchableDevEnvironment(envName, envConfig, await initEnvRunner(ctx), entry, {...});
  (ctx._viteEnvs ??= new Map()).set(envName, entry);
  return env;
},

FetchableDevEnvironment.init() already announces the environment itself after super.init(), so nothing is lost by keeping it out of _viteEnvs until it exists; the onReady replay then only ever names environments whose hot channel is listening.

Worth considering alongside it: the worker's dropped-invoke path is silent for a full 60s and unrecoverable afterwards. Having ViteEnvRunner retry a reload that failed with a transport timeout, or having _viteEnvs hold the environment rather than just its entry path, would make this class of race self-healing rather than fatal.