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.
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 a device loses its network without a reporter telling the client, the socket can look healthy for up to 35 seconds, until its next health check fails. A reported loss marks the socket down at once.
The status isn't an event: subscribe to the store you mean.
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 waits before showing a drop
pingIntervalMs: 25_000, // how often a health check goes out
},
},
});The values above are the defaults:
pingIntervalMscan only be lowered: 25 seconds is also the maximum, because a slower ping risks the server closing the connection as idle.- The socket counts as dead after
pingIntervalMs + healthCheckGracePeriodMswithout an answer, 35 seconds by default. Lowering the ping makes a dead socket noticed sooner. offlineNotificationDisplayDelayMsis the one setting the client doesn't act on. It's there so every UI waits the same time.enableWSFallback: truefalls back to HTTP long-polling when the WebSocket can't connect, for networks that block WebSockets. The store then follows the long-poll.
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 one for you, based on
navigator.onLineand theonlineandofflineevents. - Elsewhere, such as in React Native, install your own. Until you do, the client copies the socket's status, 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, after the client exists:
client.networkConnection.setStatusReporter(reporter);The reporter set last wins, and both ways survive later client.config.set() calls. Setting the same function again does nothing. If the reporter throws, the status stays unknown. A host with no event to listen to can report the status directly with client.networkConnection.setStatus(isOnline).
Don't hold back requests until the network reports online. Everything works while the status is unknown, and the client doesn't need it: it only helps notice a drop, and a return, sooner.
Closing and reopening the socket on mobile
A mobile app receives push notifications only while it has no open socket. So when the app goes to the background, close the socket, and reopen it when the app comes back:
// The app went to the background.
await client.closeConnection();
// The app is back in the foreground.
await client.openConnection();client.closeConnection() keeps the user connected to the client, and doesn't reconnect by itself. The socket store records the close as a drop. Reopening counts as a reconnect, so recovery runs, as below.
Connection recovery
What reloads after a reconnect
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:
- Refreshes every channel list that has loaded. Each list queries again, page by page, the channels it has loaded, up to 150, and keeps them on screen 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 list's filters while they were away. Channels loaded beyond the first 150 are dropped, and load again when the list is scrolled to them. These requests watch their channels again.
- Reloads every active channel. An active channel is one something opened with
Channel.activate(). The channel watches again and refreshes its newest loaded interval of messages, not the older intervals a jump loaded: messages deleted there while the user was away disappear, and their unsent messages stay. - Reloads every active thread's replies.
- Marks every other thread the client holds as stale. A stale thread loads again the next time it's opened.
- Dispatches
connection.recoveredonce the active channels and threads have reloaded. The channel lists refresh alongside them, and can finish later.
After that event, the thread list loads again, if it was opened in this session.
With offline support, steps 1 and 2 wait until the changes the user made while offline have been sent. A first connection isn't a reconnect, so none of this runs then. The UI layer activates the channels and threads it shows, so they're the ones that reload. 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 steps above watch again the channels each list loaded again and the active channels.
Other channels the user's client holds keep watchStatus set to wasWatching: channels a list had loaded beyond the first 150, channels they opened earlier, channels no list shows. 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.
connection.recovered
connection.recovered fires after every reconnect: an automatic one, and client.closeConnection() followed by client.openConnection(). It carries nothing but its type.
By the time it fires, the client has reloaded its active channels and threads, and its channel lists 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
An app that brings its data up to date itself can turn recovery off:
client.config.set({ client: { connectionRecovery: { enabled: false } } });Then a reconnect refreshes no channel list, reloads no channel or thread, and marks no thread stale. connection.recovered still fires after every reconnect, so the app can run its own recovery when it arrives. The setting is read at each reconnect, so a change applies from the next one.
To run a recovery yourself, call client.connectionRecovery.recover(). It does steps 1 to 5 above, whether or not recovery runs on its own. It does nothing while a recovery is already running.