Environment
- Nitro: 2.13.4
- Nuxt: 4.2.2,
@nuxt/cli 3.35.1 (also reproduced against 3.36.0)
- Node: v24 (macOS); the same code path is present on all platforms
- Preset:
node dev worker (default nuxt dev)
Reproduction
Any nuxt dev session where a proxied websocket dies:
npx nuxi init repro && cd repro && npm i && npm run dev
- Open
http://localhost:3000 and leave the tab open.
- Edit a file that ends up in the server bundle (e.g. a
server/api/* handler, or a module imported by one), so the dev worker is replaced.
- Repeat, or keep any client that reconnects pointed at the port.
The dev server then prints and restarts itself:
ERROR [unhandledRejection] read ECONNRESET
at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17)
● Restarting Nuxt due to error: Error: read ECONNRESET
I could not attach a StackBlitz because the failure needs a websocket to be alive across a worker swap, which the in-browser container does not really exercise — but the cause is a plain unhandled promise, visible in the source, and I verified it by instrumentation rather than by guessing (below).
Describe the bug
DevServer.handleUpgrade is async, and both of its callers drop the promise it returns:
// src/core/dev-server/server.ts
upgrade: (req, socket, head) => devServer.handleUpgrade(req, socket, head),
// ...
listener.server.on("upgrade", (req, sock, head) =>
this.handleUpgrade(req, sock, head)
);
It can reject in two ways: createError({ statusCode: 503 }) when no worker is available, and — far more often — from NodeDevWorker.handleUpgrade, which returns proxy.ws(...) without awaiting or catching it:
// src/core/dev-server/worker.ts
handleUpgrade(req: IncomingMessage, socket: Socket, head: any) {
if (!this.ready) {
return;
}
return this.#proxy!.proxy.ws(req, socket, { target: this.#address, xfwd: true }, head);
}
Every time the dev worker is replaced, the websockets proxied to it die and that promise rejects with ECONNRESET. Nothing catches it, so it becomes an unhandled rejection.
For Nitro alone that is a stray warning. Under Nuxt it ends the session: @nuxt/cli treats any unhandled rejection as fatal and restarts the dev server. With a client that keeps reconnecting it loops, and the dev server never stays up long enough to be useful. It has been reported against the CLI several times — nuxt/cli#247, nuxt/cli#968, nuxt/cli#994 — where it reads as a Windows/HMR flake, but the leak is here and platform-independent.
Additional context
I traced it rather than inferred it: I wrapped both candidate call sites in a real project and logged which one rejected. Over 30 seconds — NodeDevWorker.handleUpgrade 8, the CLI request handler 0. Adding a .catch() at that call site removed the rejections and the restarts entirely.
I am opening a PR against v2 that makes handleUpgrade never reject: it closes the socket on failure and only logs errors that are not a peer disconnect (there is nothing to reply to the client with once a handshake is in flight). main has the same shape in src/dev/server.ts (upgrade() is async and server.on("upgrade", ...) drops the promise), so it likely wants the same treatment — happy to follow up there if you would like.
Logs
ERROR [unhandledRejection] read ECONNRESET
at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17)
│
● Restarting Nuxt due to error: Error: read ECONNRESET
Environment
@nuxt/cli3.35.1 (also reproduced against 3.36.0)nodedev worker (defaultnuxt dev)Reproduction
Any
nuxt devsession where a proxied websocket dies:npx nuxi init repro && cd repro && npm i && npm run devhttp://localhost:3000and leave the tab open.server/api/*handler, or a module imported by one), so the dev worker is replaced.The dev server then prints and restarts itself:
I could not attach a StackBlitz because the failure needs a websocket to be alive across a worker swap, which the in-browser container does not really exercise — but the cause is a plain unhandled promise, visible in the source, and I verified it by instrumentation rather than by guessing (below).
Describe the bug
DevServer.handleUpgradeisasync, and both of its callers drop the promise it returns:It can reject in two ways:
createError({ statusCode: 503 })when no worker is available, and — far more often — fromNodeDevWorker.handleUpgrade, which returnsproxy.ws(...)without awaiting or catching it:Every time the dev worker is replaced, the websockets proxied to it die and that promise rejects with
ECONNRESET. Nothing catches it, so it becomes an unhandled rejection.For Nitro alone that is a stray warning. Under Nuxt it ends the session:
@nuxt/clitreats any unhandled rejection as fatal and restarts the dev server. With a client that keeps reconnecting it loops, and the dev server never stays up long enough to be useful. It has been reported against the CLI several times — nuxt/cli#247, nuxt/cli#968, nuxt/cli#994 — where it reads as a Windows/HMR flake, but the leak is here and platform-independent.Additional context
I traced it rather than inferred it: I wrapped both candidate call sites in a real project and logged which one rejected. Over 30 seconds —
NodeDevWorker.handleUpgrade8, the CLI request handler 0. Adding a.catch()at that call site removed the rejections and the restarts entirely.I am opening a PR against
v2that makeshandleUpgradenever reject: it closes the socket on failure and only logs errors that are not a peer disconnect (there is nothing to reply to the client with once a handshake is in flight).mainhas the same shape insrc/dev/server.ts(upgrade()isasyncandserver.on("upgrade", ...)drops the promise), so it likely wants the same treatment — happy to follow up there if you would like.Logs