Skip to content

dev: unhandled ECONNRESET when a proxied websocket dies with its workerΒ #4493

Open

Description

@kedrzu

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:

  1. npx nuxi init repro && cd repro && npm i && npm run dev
  2. Open http://localhost:3000 and leave the tab open.
  3. 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.
  4. 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

Activity

  1. devmrse commented on Aug 14, 2026

    @devmrse

    Can confirm β€”> hit this exact issue on Nitro 2.13.4 / Nuxt 3.21.11 (nuxt dev, node-server preset), reproducible on nearly every dev-server restart, not just on worker replacement.

    I traced it independently before finding this issue, using an async_hooks init hook to capture the creation stack of the TCP resource behind the unhandled rejection (the default stack TCP.onStreamRead / TCP.callbackTrampoline has no useful async context on its own). That pointed straight to the same place described here:

    DevServer.handleUpgrade (nitropack/dist/core/index.mjs)
    -> NodeDevWorker.handleUpgrade -> this.#proxy.proxy.ws(req, socket, { target, xfwd: true }, head)

    Both callers drop the returned promise:

    • nitropack's createDevServer() exposes upgrade: (req, socket, head) => devServer.handleUpgrade(req, socket, head) with no .catch().
    • @nuxt/cli's dev server registers this.listener.server.on("upgrade", ...) and calls nuxt.server.upgrade(req, socket, head) fire-and-forget (no await/.catch()), so the rejection becomes a process-level unhandledRejection, which @nuxt/cli treats as fatal and restarts on.

    I also found nitropack's createHTTPProxy() never attaches an 'error' listener to the httpxy proxy instance it creates β€” a second, related gap in the same function, since proxy libraries built on Node's EventEmitter conventions treat an unlistened 'error' event as fatal too.

    Workaround (via patch-package, three small patches, no functional changes otherwise):

    1. nitropack: added a proxy.on("error", ...) handler in createHTTPProxy() that ends/destroys the socket or response instead of leaving it unhandled.
    2. @nuxt/cli: wrapped the nuxt.server.upgrade(req, socket, head) call in Promise.resolve(...).catch(() => socket.destroy()).
    3. listhen: added clientError/connection/error handlers on the dev HTTP server itself, since it had the same class of gap for plain (non-WS) socket resets.

    With all three applied, 80 forced-RST connections (including ones with WebSocket upgrade headers, to hit this exact path) against a running dev server no longer crash it, previously it restarted within seconds, deterministically. Happy to share the exact patch diffs if useful; the fix in the linked PR looks like it covers the same core gap (never letting handleUpgrade reject) plus more.

  2. fabis94 commented on Aug 14, 2026

    @fabis94

    i have run into the same issues! i see there's PRs to treat this, would be great to see those reviewed and merged in :)

  3. nnshx commented on Aug 27, 2026

    @nnshx

    Confirming this on newer versions, with an additional trigger that doesn't involve a worker restart.

    Environment: nuxt 4.5.2, @nuxt/cli 3.37.0, nitropack 2.13.4, Node v24.19.0, Linux (Debian 13). nitro.experimental.websocket: true.

    Additional trigger β€” client-side abort is enough. No file edit / worker restart needed: if a client aborts an established WebSocket connection with a TCP RST, the dev server hits the same unhandled path. In 5 aborts we got 2–3 restarts:

    ●  Restarting Nuxt due to error: Error: read ECONNRESET
    

    In practice a browser refresh of a tab holding an open WebSocket can produce this teardown, so during development, one user reloading a page can restart the dev server for everyone. We also once observed the process dying outright instead of restarting, with the classic unhandled-'error'-event crash (node:events throw, Emitted 'error' event on Socket instance).

    Minimal reproduction (3 files + a script):

    // package.json
    {
      "name": "nuxt-ws-rst-repro",
      "private": true,
      "type": "module",
      "scripts": { "dev": "nuxt dev" },
      "dependencies": { "nuxt": "4.5.2" }
    }
    // nuxt.config.ts
    export default defineNuxtConfig({
      nitro: { experimental: { websocket: true } }
    })
    // server/routes/ws.ts
    export default defineWebSocketHandler({
      open(peer) {
        peer.send('hello')
      },
      message() {}
    })
    // repro.mjs β€” run `pnpm dev --port 3210`, then `node repro.mjs`
    import net from 'node:net'
    import crypto from 'node:crypto'
    const PORT = Number(process.env.PORT ?? 3210)
    const sleep = ms => new Promise(r => setTimeout(r, ms))
    
    for (let i = 1; i <= 5; i++) {
      await new Promise((resolve) => {
        const key = crypto.randomBytes(16).toString('base64')
        const sock = net.connect(PORT, 'localhost')
        sock.on('error', () => resolve())
        sock.on('connect', () => sock.write(
          `GET /ws HTTP/1.1\r\nHost: localhost:${PORT}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n`
          + `Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`))
        sock.once('data', () => {
          setTimeout(() => { sock.resetAndDestroy(); resolve() }, 150)
        })
        setTimeout(resolve, 3000)
      })
      console.log(`RST ${i}/5`)
      await sleep(300)
    }

    Isolation, for what it's worth:

    • Plain HTTP aborts (RST mid-response, 10Γ—): no restart β€” it's specific to proxied WebSockets.
    • Clean closes (ws.close()): no restart.
    • The production preset (node .output/server/index.mjs) survives the identical abort patterns (including RSTs of fully-established connections and writes to already-reset sockets) with no errors β€” consistent with the dev-proxy diagnosis in the OP.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingnuxtv2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions