Skip to content

TypeScript SDK: ws.onerror on an established connection fires onConnectError and silently stalls the client instead of routing to onDisconnect #5706

Description

@sephirith

Describe the bug

In the TypeScript SDK, the ws.onerror handler treats every websocket error as a connect failure, regardless of whether the connection was already established:

https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings-typescript/src/sdk/db_connection_impl.ts

this.ws.onclose = () => {
  this.isActive = false;
  this.#emitter.emit('disconnect', this);
};
this.ws.onerror = (e: ErrorEvent) => {
  this.isActive = false;
  this.#emitter.emit('connectError', this, e);
};

For an error on an already established connection (one that received InitialConnection and fired onConnect), three things go wrong:

  1. onConnectError fires for a mid-session transport error. The builder documents onConnectError as a connect-time failure callback, and consumers commonly wire it to boot-time recovery (retry auth, clear stored token, show a login screen). A mid-session error takes that path instead of the disconnect/reconnect path.
  2. isActive = false disables the outbound send path (#sendEncodedMessage checks this.ws && this.isActive) while the socket may still be OPEN. Reducer and procedure calls are silently queued from that point on, and nothing tears the socket down, so onDisconnect never fires and no reconnect logic runs. The client stalls in a zombie state until the browser eventually delivers onclose, if it ever does.
  3. Even when onclose does arrive, the disconnect emit carries no error argument, so the error that ended the connection is lost.

This contradicts the SDK's own documented contract on onDisconnect in db_connection_builder.ts:

Registers a callback to run when a DbConnection whose connection initially succeeded is disconnected, either after a DbConnection.disconnect() call or due to an error.

If the connection ended because of an error, the error is passed to the callback.

Note that this does not trigger if build fails or in cases where DbConnectionBuilder.onConnectError would trigger.

The docs draw a clear line: connect-time failures go to onConnectError, post-connect errors go to onDisconnect with the error attached. The implementation routes both to onConnectError and never passes an error to onDisconnect.

We hit this in production (Tidefall, spacetimedb@2.7.1): a mid-session websocket error fired our onConnectError handler, which is wired to login recovery, while our reconnect logic (wired to onDisconnect) never ran. The player kept an apparently live UI whose reducer calls were being silently queued.

Expected behavior

A websocket error on an established connection should surface through the disconnect path: the socket is torn down, onDisconnect fires with the error, and existing reconnect handling takes over. onConnectError should only fire for failures before the initial connection succeeds.

Suggested fix

Track whether InitialConnection was received. In onerror, if the connection was established, record the error and close the socket so the existing onclose path emits disconnect, and pass the recorded error through to the disconnect emit:

this.ws.onclose = () => {
  this.isActive = false;
  this.#emitter.emit('disconnect', this, this.#connectionError);
};
this.ws.onerror = (e: ErrorEvent) => {
  this.isActive = false;
  if (this.#everConnected) {
    this.#connectionError = e;
    this.ws?.close();
    return;
  }
  this.#emitter.emit('connectError', this, e);
};

onDisconnect callbacks already accept an optional error argument, so passing it is additive and matches the documented behavior.

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