Connection

A chat connection can fail in two ways. The device can lose its network, or the client's WebSocket can drop while the network is fine: the server closed it, the token expired, or a health check timed out. The client reports the two separately, so an app can tell "you're offline" from "reconnecting".

When the socket comes back, the client brings what the user is looking at up to date by itself, then announces it with connection.recovered.

On this page, a reconnect is the client's WebSocket opening again after it dropped: when the network comes back, when the device wakes from sleep, when the client reopens a socket the server closed, or after client.closeConnection() and client.openConnection(). The client's first connection isn't a reconnect.

This page covers both: the connection status, then recovery. Showing a lost-connection banner is on Connection status, and reading these stores in components on Reading state in components.

Connection status

Question Where to read it
Is the WebSocket up? client.wsConnection.state, isHealthy
Does the device have a network? client.networkConnection.state, isOnline
Has the client caught up after reconnect? the connection.recovered event

The two stores can disagree either way. A socket can drop on a working network. And when the device loses its network, the socket can look healthy for up to 35 seconds, until its next health check fails, unless something tells the client about the loss. In a browser, the client learns it at once from the browser's offline event and marks the socket down. Elsewhere you install a network status reporter, as Device network explains.

No event announces when the socket goes up or down, or when the device goes online or offline. To follow them, subscribe to client.wsConnection.state for the socket, or client.networkConnection.state for the network.

Socket health

client.wsConnection.state holds { isHealthy, lastHealthyAt, lastUnhealthyAt }. isHealthy is always a boolean, so !isHealthy is safe. client.wsConnection.isHealthy reads the current value.

The store records every change, including a deliberate client.closeConnection(), and it records a drop the moment it happens. Most drops recover in under a second, so a UI that shows every drop at once makes a working app look broken. Wait offlineNotificationDisplayDelayMs before showing one, and cancel the wait if the socket comes back:

const delayMs = client.wsConnection.config.offlineNotificationDisplayDelayMs;
let timer: ReturnType<typeof setTimeout> | undefined;

const unsubscribe = client.wsConnection.state.subscribeWithSelector(
  ({ isHealthy }) => ({ isHealthy }),
  ({ isHealthy }) => {
    clearTimeout(timer);
    if (isHealthy) {
      console.log("Connected");
      return;
    }
    timer = setTimeout(() => console.log("Reconnecting…"), delayMs);
  },
);

The socket's timings are set through client.config, which Configuration explains:

client.config.set({
  client: {
    wsConnection: {
      connectTimeoutMs: 15_000, // how long connecting waits for the server
      healthCheckGracePeriodMs: 10_000, // extra time before a silent socket counts as dead
      offlineNotificationDisplayDelayMs: 5_000, // How long a UI should wait before showing a drop
      pingIntervalMs: 25_000, // how often a health check goes out
    },
  },
});

The values above are the defaults. client.wsConnection takes these options:

Option Default What it does
connectTimeoutMs 15 s How long connecting waits for the server before it fails.
pingIntervalMs 25 s How often a health check goes out. It can only be lowered, to 1 s at least: a slower ping risks the server closing the connection as idle. Lowering it notices a dead socket sooner.
healthCheckGracePeriodMs 10 s Extra time before a silent socket counts as dead: after pingIntervalMs + healthCheckGracePeriodMs without an answer, 35 s by default. At least 1 s.
offlineNotificationDisplayDelayMs 5 s How long a UI should wait before showing a drop. The client doesn't act on it. It's there so every UI waits the same time.
enableWSFallback false true falls back to HTTP long-polling when the WebSocket can't connect, for networks that block WebSockets. The socket store then follows the long-poll.
webSocketImpl the global WebSocket The WebSocket constructor the client opens the socket with.
urlParams none Extra query parameters added to the WebSocket URL.
connection none A StableWSConnection built in advance, used instead of opening a socket. It's meant for tests: the client uses it only outside a browser, when there's no global window or the client's browser setting is false, and client.config.reset() discards it.

A timing value outside its bounds is moved to the nearest bound, and the client logs a warning.

Device network

client.networkConnection.state holds { isOnline, lastOnlineAt, lastOfflineAt }, and client.networkConnection.isOnline reads the current value.

isOnline is undefined when the status is unknown: nothing has reported it yet. Test it with === false:

if (client.networkConnection.isOnline === false) {
  // The device is offline.
}

// Wrong: also true while the status is unknown, such as before the first connection.
if (!client.networkConnection.isOnline) {
  // ...
}

The client can't detect the network by itself: every platform reports it differently. So a reporter tells it. A reporter is a function that reports the current status, reports again on every change, and returns a function that stops it.

In a browser, the client installs a reporter for you, based on navigator.onLine and the online and offline events. Install your own when you know the network status better than the browser does, for example from a host app that embeds the chat in a web view. Outside a browser, until a reporter is installed, the network status is inferred from the socket's connection state, which can't tell a dead socket from a lost network.

import type { NetworkStatusReporter } from "stream-chat";

const reporter: NetworkStatusReporter = (onStatusChange) => {
  // Report the current status right away, then on every change.
  onStatusChange(navigator.onLine);

  const handle = () => onStatusChange(navigator.onLine);
  window.addEventListener("online", handle);
  window.addEventListener("offline", handle);

  return () => {
    window.removeEventListener("online", handle);
    window.removeEventListener("offline", handle);
  };
};

client.config.set({
  client: { networkConnection: { statusReporter: reporter } },
});
// or, with the same effect:
client.networkConnection.setStatusReporter(reporter);

client.networkConnection takes this option:

Option Default What it does
statusReporter in a browser, one based on navigator.onLine; elsewhere, one that infers it from the socket's connection state The function that reports the device's network status.

The client uses the reporter installed last: a setStatusReporter() call replaces a reporter from client.config, and setting statusReporter in client.config replaces one from setStatusReporter(). A client.config.set() call that doesn't set statusReporter keeps the installed reporter. Installing the same function again does nothing, and client.networkConnection.setStatusReporter(null) removes the reporter and keeps the last status.

If the reporter throws while the client installs it, the client logs an error and installs nothing in its place, so isOnline stops changing. A host with no event to listen to can report the status directly with client.networkConnection.setStatus(isOnline).

Don't wait for isOnline to be true before sending requests. Requests don't depend on it, and they work while it's undefined. A reporter only makes the client faster: it notices a lost network before the socket's health check fails, and reconnects as soon as the network comes back instead of waiting for its next retry.

Connection recovery

What reloads after a reconnect

Info:

Example

The user's laptop sleeps for ten minutes. While it sleeps, they miss messages, reactions and read receipts, and the server ends every watch their client held. When the socket comes back, the client:

  1. Refreshes every channel paginator that has loaded. Each paginator queries again, page by page, the channels it has loaded, up to 150, and the queries watch those channels again. The paginator keeps its channels meanwhile. Then it removes the loaded channels no page returned: channels that were deleted, that the user left, or that were hidden, archived or stopped matching the paginator's filters while the client was disconnected. Channels loaded beyond the first 150 leave the paginator, and load again when the paginator loads more. The client still holds them, with watchStatus in channel.state set to wasWatching, as explained below.
  2. Reloads every active channel. An active channel is one something opened with Channel.activate(). The channel watches again and reloads its newest loaded interval of messages. Messages in that interval that were deleted while the client was disconnected disappear, and the user's unsent messages stay. Older intervals that a jump loaded aren't reloaded.
  3. Reloads every active thread's replies.
  4. Marks every other thread the client holds as stale. A stale thread loads again the next time it's opened.
  5. Dispatches connection.recovered once the active channels and threads have reloaded. The channel paginators refresh alongside them, and can finish later.

After that event, the thread list loads again, if it was opened in this session.

A first connection isn't a reconnect, so none of this runs then. <Channel/> activates the channel it shows, so that channel reloads. <Thread/> doesn't activate its thread. A thread reloads only while something holds it active, such as useActiveThread, which the app calls for the thread it shows. Otherwise it's marked stale and loads again when it's opened. Entity lifecycle explains Channel.activate() and Thread.activate().

Channels re-watched on demand

A watch is what makes the server send a channel's events to this client. The server ties watches to the connection, so a reconnect ends them all. The recovery steps watch again the channels each paginator loaded again, and the active channels.

The other channels the client holds aren't watched again. Their watchStatus in channel.state stays wasWatching. These include channels beyond the first 150 a paginator had loaded, channels opened earlier that aren't active, and channels no paginator holds. Watching them all again could exceed the server's watch limit. Instead, each one is watched again when the channel manager routes it after an event, for example notification.message_new when someone writes in it.

Event connection.recovered

connection.recovered fires after every reconnect: an automatic one, and client.closeConnection() followed by client.openConnection(). It doesn't fire on the first connection.

While recovery is on, the client has reloaded its active channels and threads by the time it fires, and its channel paginators are refreshing. Listen for it to refresh what the client doesn't own: results of one-off queries, counters, your own data.

For example, an app that shows the user's total unread count from a one-off query loads it again:

let totalUnread = 0;

const loadTotalUnread = async () => {
  const { total_unread_count } = await client.unreadCounts();
  totalUnread = total_unread_count;
};

const { unsubscribe } = client.on("connection.recovered", () => {
  void loadTotalUnread();
});

If the socket drops again while the client reloads, the event waits for the next reconnect, whose recovery fires it.

Turning recovery off or running it by hand

Turn recovery off when your app refreshes its data itself. The setting is read at each reconnect, so a change applies from the next one:

client.config.set({ client: { connectionRecovery: { enabled: false } } });

With recovery off, a reconnect refreshes nothing, and nothing is watched again:

Part After a reconnect, with recovery off
connection.recovered Fires at once, before anything is reloaded.
Channel lists (channel paginators) Not refreshed. They keep the channels and the order they had before the drop, and their channels aren't watched again.
Channel instances, including the open one Not reloaded and not watched again, so they get no events. A channel is watched again only when an event for it arrives, such as notification.message_new, as Channels re-watched on demand explains.
Thread instances, including the open one Not reloaded and not marked stale. A thread needs its own reload: watching its channel again doesn't reload its replies.
Thread list Reloads, if it was opened in this session. The threads in it keep their old replies and counts.

So refresh what the user sees in a connection.recovered listener:

To refresh Call
the channel lists client.channelManager.recover(). It refreshes each channel paginator that has loaded, as step 1 of What reloads after a reconnect does, and watches its channels again.
the open channel Channel.reload(). It watches the channel again and reloads its newest messages.
the open thread, the threads shown in the thread list, and a thread the user opens again Thread.reload().

Channel.reload() and Thread.reload() reject when the request fails, so catch the error in your listener.

To run the SDK's recovery yourself, with recovery on or off, call client.connectionRecovery.recover(). It does the steps in What reloads after a reconnect:

  • It fires connection.recovered once the active channels and threads have reloaded. The channel paginators can finish later.
  • It holds the event back if the socket dropped while it ran, or is down when it finishes.
  • It does nothing while the active channels and threads are still reloading.
  • Don't call it from a connection.recovered listener. It fires the event itself, so the listener would call it again and again.

Used in