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
-
connectOnce() arms setTimeout(…, connectTimeout). On expiry it calls
failAttempt(err), which calls this.discardPendingSocket() and then rejects.
-
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.
-
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;
}
-
abortHandshake() defers the notification (ws/lib/websocket.js:1121):
process.nextTick(emitErrorAndClose, websocket, err);
-
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.
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 theCONNECTINGstate.Summary
When a streaming connection attempt times out,
StreamingTranscribertears down thehalf-open socket via
discardPendingSocket(), which callssocket.removeAllListeners()and then
socket.close(). Because the socket is still inCONNECTING,wsaborts thehandshake and emits
erroron the next tick — by which point the socket has noerrorlistener, so Node escalates it to anuncaughtExceptionand the process dies.The
try { … } catch {}insidediscardPendingSocket()does not help, because the emitis 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
maxConnectionRetriescan produce asuccessful attempt.
Environment
assemblyai@4.35.4ws@8.21.0(resolved from the SDK's own"ws": "^8.18.0")dist/node.mjs(thenode+importexport condition)Stack trace
(The frames below
WebSocket.closecome fromError.captureStackTrace(err, abortHandshake)in
ws, so they show where the error was created, not where it was thrown. It is thrownfrom the
process.nextTickqueue.)Root cause
connectOnce()armssetTimeout(…, connectTimeout). On expiry it callsfailAttempt(err), which callsthis.discardPendingSocket()and then rejects.discardPendingSocket()(dist/node.mjs:1407):removeAllListeners()strips theonerror/onclosehandlers thatconnectOnce()installed, so the socket now has zero
errorlisteners.The socket is still
CONNECTING(the timeout fired precisely because the handshake hadnot completed), so
wstakes this branch inWebSocket.prototype.close(
ws/lib/websocket.js:302-307):abortHandshake()defers the notification (ws/lib/websocket.js:1121):On that tick,
emitErrorAndCloserunswebsocket.emit('error', err).ws.WebSocketextends
EventEmitter, and emitting'error'with no registered listener throws theerror. It is now outside the
try/catchin step 2 and outside any caller'stry/catcharoundawait transcriber.connect(), so it surfaces as anuncaughtException.The
connect()promise separately rejects withStreaming connection timed out after Nms, which callers can and do handle — the crash is a second, unhandleable consequence ofthe same timeout.
Reproduction
Any environment where the handshake cannot finish within
connectTimeoutreproduces this.The shortest version just uses an aggressively small timeout against the real endpoint
(no valid API key required — the socket is
CONNECTINGwell before any auth response):Real-world trigger: a cold cross-region TLS + WebSocket handshake under load exceeding a
conservative
connectTimeout(we hit this regularly withconnectTimeout: 3000againstwss://streaming.eu.assemblyai.com/v3/ws).The same pattern exists in
close()StreamingTranscriber.close()(dist/node.mjs:1709) ends with the identical sequence, andthere it is not wrapped in a
try/catchat all:So calling
close()while a connection is still being established — e.g. a shutdown orabort path racing the handshake — crashes the process the same way. Reproducible by
calling
transcriber.close()immediately aftertranscriber.connect()without awaiting it.Suggested fix
Guarantee that the socket has an
errorlistener at the momentwsperforms its deferredemit. A no-op sink is sufficient, since the real failure is already reported through the
rejected
connect()promise:The
removeAllListenerscheck already gates the Node-specific path, so browser builds areunaffected (a browser
WebSocket.close()on aCONNECTINGsocket is a no-op and does notthrow). The same guard should be applied in
close().Alternatives that would also work:
removeAllListeners()entirely and instead rely on the existingsettledflag,which already makes the handlers inert after an attempt has failed.
socket.close()whenreadyState !== CONNECTING, and usesocket.terminate()(Node) for the half-open case.