Connection state

The state machine, the reconnect policy, and how to tell a flaky network apart from a refused credential.

The states

initialized → connecting → connected
                  │            │
                  ▼            ▼
               failed     disconnected → (backoff) → connecting


                             closed
realtime.connection.on('state', (state, reason) => {
  console.log(state, reason?.message);
});

realtime.connection.off('state', listener);

realtime.connection.state;        // current
realtime.connection.errorReason;  // BlackevinError | null

reason is a second argument, so a listener written with one parameter keeps working.

What moves it

eventstate
connect()connecting
Connected frameconnected — restores attaches and subscriptions, clears errorReason
socket closed, not by youdisconnected, then reconnect with backoff
close()closed, no reconnect
auth Nack 401 / 403failed — stops retrying, rejects the queue
quota Nack 429disconnected — keeps retrying, sets errorReason

Reconnect is exponential: disconnectedRetryTimeout (1s) doubled per attempt, capped at 30s. Dynamic credentials (authCallback, authUrl) are cleared before each attempt so a fresh token is fetched; a static key or token is kept.

errorReason

Why the connection is not up, when the server said why. A BlackevinError with a statusCode, an optional reason slug, and the server's own message.

It exists because a refused handshake used to reach nobody. The server sends a Nack on id: "auth", nothing is awaiting that id, and then the socket closes — leaving the client disconnected, which looks exactly like a flaky network. An application hitting its plan's connection ceiling could not tell its user anything true.

realtime.connection.on('state', (state, reason) => {
  if (reason?.reason === 'connection_limit') {
    showUpgradePrompt(reason.message);
  }
});

Branch on reason.reason, never on reason.message. The slugs are a contract and are never renamed; the messages are prose and will be reworded.

A 429 is not terminal

A bad credential will never work, so a 401 or 403 sends the client to failed: it stops retrying and rejects everything queued. Retrying a wrong key forever only produces load.

A connection ceiling is the opposite. A slot frees the moment another client disconnects, and an upgrade takes effect on the next handshake. So a 429 keeps retrying, and queued publishes are held rather than rejected — throwing them away would discard exactly the work the offline queue exists to keep.

What it must not do is retry silently, which is what errorReason fixes.

Closing

realtime.close();

closed is final. autoReconnect is switched off permanently, anything queued is rejected, and the client will not come back until you call connect() yourself.

That distinction matters on logout: dropping the token is not enough if the client can still reconnect with a cached one. Close it.

Forcing a client off

A backend can drop every live session for a clientId under its account:

POST /api/clients/{clientId}/connections/close
{ "closed": 2 }

closed counts the sockets on the node that answered; peers close their own independently and are not summed.

This is a revoke for stuck or zombie connections — a browser whose beforeunload never reached the server. It is not exclusive-login: a client that still holds a valid token and has autoReconnect on will simply come back. A logout flow needs to invalidate the token too.

Only connections with a bound clientId are targets, which in practice means connections authenticated by a JWT carrying x-blackevin-client-id. A plain API-key connection has no client identity to match on.

On this page