Skip to content

error: WebSocket was closed before the connection was established #170

Description

@richtrwhereby

We noticed that this happened in our production usage a couple of times so did a Claude-based analysis of the issue and I have included that below.

tl;dr we are getting uncaught exceptions that are crashing our Node process when discardPendingSocket() closes a websocket that is in the CONNECTING state.

Summary

When a streaming connection attempt times out, StreamingTranscriber tears down the
half-open socket via discardPendingSocket(), which calls socket.removeAllListeners()
and then socket.close(). Because the socket is still in CONNECTING, ws aborts the
handshake and emits error on the next tick — by which point the socket has no
error listener, so Node escalates it to an uncaughtException and the process dies.

The try { … } catch {} inside discardPendingSocket() does not help, because the emit
is deferred and therefore lands on an empty stack.

In practice this makes the timeout branch of the built-in connect retry loop unusable on
Node: the process crashes on the next tick, before maxConnectionRetries can produce a
successful attempt.

Environment

  • assemblyai@4.35.4
  • ws@8.21.0 (resolved from the SDK's own "ws": "^8.18.0")
  • Node.js v24.8.0, Linux (also reproduces on macOS)
  • Entry point: dist/node.mjs (the node + import export condition)

Stack trace

Error: WebSocket was closed before the connection was established
    at WebSocket.close (/opt/node_modules/ws/lib/websocket.js:306:7)
    at StreamingTranscriber.discardPendingSocket (file:///opt/node_modules/assemblyai/dist/node.mjs:1413:25)
    at failAttempt (file:///opt/node_modules/assemblyai/dist/node.mjs:1276:22)
    at Timeout.<anonymous> (file:///opt/node_modules/assemblyai/dist/node.mjs:1291:21)
    at listOnTimeout (node:internal/timers:605:17)
    at process.processTimers (node:internal/timers:541:7)

(The frames below WebSocket.close come from Error.captureStackTrace(err, abortHandshake)
in ws, so they show where the error was created, not where it was thrown. It is thrown
from the process.nextTick queue.)

Root cause

  1. connectOnce() arms setTimeout(…, connectTimeout). On expiry it calls
    failAttempt(err), which calls this.discardPendingSocket() and then rejects.

  2. discardPendingSocket() (dist/node.mjs:1407):

    discardPendingSocket() {
        if (!this.socket) return;
        try {
            if (this.socket.removeAllListeners) this.socket.removeAllListeners();
            this.socket.close();
        } catch {
            // Best-effort cleanup; a half-open socket may throw on close.
        }
        this.socket = undefined;
    }

    removeAllListeners() strips the onerror / onclose handlers that connectOnce()
    installed, so the socket now has zero error listeners.

  3. The socket is still CONNECTING (the timeout fired precisely because the handshake had
    not completed), so ws takes this branch in WebSocket.prototype.close
    (ws/lib/websocket.js:302-307):

    if (this.readyState === WebSocket.CONNECTING) {
      const msg = 'WebSocket was closed before the connection was established';
      abortHandshake(this, this._req, msg);
      return;
    }
  4. abortHandshake() defers the notification (ws/lib/websocket.js:1121):

    process.nextTick(emitErrorAndClose, websocket, err);
  5. On that tick, emitErrorAndClose runs websocket.emit('error', err). ws.WebSocket
    extends EventEmitter, and emitting 'error' with no registered listener throws the
    error. It is now outside the try/catch in step 2 and outside any caller's
    try/catch around await transcriber.connect(), so it surfaces as an
    uncaughtException.

The connect() promise separately rejects with Streaming connection timed out after Nms, which callers can and do handle — the crash is a second, unhandleable consequence of
the same timeout.

Reproduction

Any environment where the handshake cannot finish within connectTimeout reproduces this.
The shortest version just uses an aggressively small timeout against the real endpoint
(no valid API key required — the socket is CONNECTING well before any auth response):

import { AssemblyAI } from "assemblyai";

const client = new AssemblyAI({ apiKey: "any-value" });

const transcriber = client.streaming.transcriber({
  sampleRate: 16000,
  encoding: "pcm_s16le",
  connectTimeout: 1,        // fires while the socket is still CONNECTING
  maxConnectionRetries: 0,
});

transcriber.on("error", (error) => console.log("error listener:", error.message));

try {
  await transcriber.connect();
} catch (error) {
  console.log("caught from connect():", error.message);
}

// The process still dies on the next tick:
// caught from connect(): Streaming connection timed out after 1ms
// Error: WebSocket was closed before the connection was established

Real-world trigger: a cold cross-region TLS + WebSocket handshake under load exceeding a
conservative connectTimeout (we hit this regularly with connectTimeout: 3000 against
wss://streaming.eu.assemblyai.com/v3/ws).

The same pattern exists in close()

StreamingTranscriber.close() (dist/node.mjs:1709) ends with the identical sequence, and
there it is not wrapped in a try/catch at all:

if (this.socket?.removeAllListeners) this.socket.removeAllListeners();
this.socket.close();

So calling close() while a connection is still being established — e.g. a shutdown or
abort path racing the handshake — crashes the process the same way. Reproducible by
calling transcriber.close() immediately after transcriber.connect() without awaiting it.

Suggested fix

Guarantee that the socket has an error listener at the moment ws performs its deferred
emit. A no-op sink is sufficient, since the real failure is already reported through the
rejected connect() promise:

discardPendingSocket() {
    if (!this.socket) return;
    try {
        if (this.socket.removeAllListeners) {
            this.socket.removeAllListeners();
            // `ws` aborts a CONNECTING handshake by emitting `error` on the next tick.
            // Keep a sink attached so that emit cannot become an uncaughtException.
            this.socket.on("error", () => {});
        }
        this.socket.close();
    } catch {
        // Best-effort cleanup; a half-open socket may throw on close.
    }
    this.socket = undefined;
}

The removeAllListeners check already gates the Node-specific path, so browser builds are
unaffected (a browser WebSocket.close() on a CONNECTING socket is a no-op and does not
throw). The same guard should be applied in close().

Alternatives that would also work:

  • Skip removeAllListeners() entirely and instead rely on the existing settled flag,
    which already makes the handlers inert after an attempt has failed.
  • Only call socket.close() when readyState !== CONNECTING, and use
    socket.terminate() (Node) for the half-open case.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions