Reconnecting and heartbeats
Connections drop: networks change, laptops sleep, servers deploy. This page covers how the client recovers, how to tune it, how to show it in your UI, and how messages sent while disconnected are handled.
Connection states
status is always one of five values:
The usual transitions:
attempt counts consecutive failed attempts. It is 0 while open, and it resets once a connection has stayed open for minUptime (5 seconds by default). A connection that closes sooner counts as a failed attempt, so attempt carries on from where it was.
Backoff
After an unexpected close, the client waits and tries again. The delay before attempt n is:
With the defaults (minDelay: 1000, factor: 2, maxDelay: 30000, jitter: 1), the delays before jitter are 1 s, 2 s, 4 s, 8 s, 16 s, then 30 s for every attempt after that. Full jitter then picks each delay at random between 0 and that value, so thousands of clients dropped by the same server deploy spread their reconnects over the whole window instead of arriving together. A lower jitter keeps more of each delay: with 0.5, it is between half and all of it.
Tune it with the reconnect option:
reconnect: false disables reconnecting entirely. The first unexpected close then makes the status closed.
Servers that close right after accepting
A server can accept a connection and close it at once, for example when it rejects the credentials sent from onOpen, or when it is overloaded. If every open reset the backoff, the client would come back about once a second, forever.
So a connection resets the backoff only once it has stayed open for minUptime. One that closes sooner counts as a failed attempt: the next delay is longer, and maxAttempts still applies. minUptime: 0 resets on every open.
Overloaded servers (1013)
A server that is shedding load can close with 1013 (Try Again Later). Coming back after a second, like after any other drop, would only add to the load, so the client waits longer:
- If the close reason is a number of seconds, like an HTTP
Retry-Afterheader (ws.close(1013, '30')on the server), the client waits that long, plus the usual backoff. Clients told the same delay still spread out over the backoff window instead of all coming back at once. - Otherwise, its backoff starts at
maxDelay: with the defaults, a delay picked at random between 0 and 30 seconds.
In both cases, coming back online or to the tab doesn't cut the wait short, and the close counts as a failed attempt, so attempt goes up and maxAttempts applies. reconnect() still connects at once.
Reading the delay is the retryAfter option, whose default is the exported retryAfter function. It reads a 1012 (Service Restart) close the same way, without the longer backoff: see Deploys. If your server says how long to wait in another way, for example with a code of its own and the delay in milliseconds, pass your own and call the default from it:
It returns a delay in ms, or undefined when the server didn't ask for one. Anything else, such as NaN from a reason that isn't a number, is ignored.
Deploys without reconnect storms
When a server restarts, every client it served reconnects at once, often to a new server that is still warming up, and each user's connection drops for a moment. Instead, the server can ask its clients to move before it stops, each at a random moment within a window, and client.migrate() follows:
migrate() waits a random time up to within ms, then opens a new connection and closes the current one once the new one is open. Until then the current connection keeps working, so there is no gap: the status stays open, send() doesn't queue, and messages keep arriving. open listeners run for the new connection as after any reconnect, so an auth handshake and topic subscriptions are sent on it. A request the old connection hadn't answered fails as on a lost connection.
urlmoves the client to another server, for example from a blue to a green deployment. It can be a string, or a function called before the connection, like the client's ownurl. Later reconnects go there too, until the client is closed, gives up or is torn down. Without it, the new connection uses the client's current URL, and a load balancer can route it to a new instance.- If the new connection fails, the current one stays. When the server stops, the client reconnects as after any other drop.
- If the client isn't open when its moment comes, only the URL changes, for its next attempt.
On the server, send the notice, wait for the window to pass, then close whoever is left with 1012 (Service Restart). Its reason can say, in seconds, when to come back: the default retryAfter reads it like a 1013's, so those clients wait that long plus the usual backoff instead of retrying within a second. With the ws package:
migrate() is also a gap-free reconnect(). Call it with no options to open a fresh connection, for example to pick up a refreshed token in a url function, without the status leaving open.
Giving up
When maxAttempts is reached, or shouldReconnect returns false, the client stops:
- the status becomes
closed; readyrejects with anErrordescribing the last close;- the send queue is cleared.
Some closes can't be fixed by trying again the same way, so by default the client gives up on them at once:
Every other close is retried, including 1001 (going away), 1006 (network loss) and 1011 (server error). This rule is the default shouldReconnect, exported as retryableClose. Your own shouldReconnect replaces it, so call retryableClose from yours to keep it, for example to also stop on your server's own code for an invalid token:
To retry every close, pass shouldReconnect: () => true.
shouldReconnect receives a CloseInfo (code, reason, wasClean). It isn't consulted after close(), which never reconnects.
An exception from the WebSocket constructor (for example a malformed URL) is never retried, because retrying can't fix it. It is reported to onError and the status becomes closed. A url function that throws or rejects, on the other hand, counts as a failed attempt and is retried.
Retrying right away
While a retry is scheduled, the client skips the rest of the wait and retries immediately when:
- the browser fires
online(the network came back), or - the tab becomes visible again (
visibilitychange).
So a laptop that wakes from sleep reconnects as soon as it can, not after a 30-second backoff. The exception is a wait the server asked for, with a 1013 close or your retryAfter.
While the connection is open, the same two events make the client check that it still works, if you configured a heartbeat. After sleep or a network change, a socket can be dead while readyState still says OPEN. So the client sends a ping right away and, with a timeout, reconnects if nothing comes back within it, instead of waiting for the next interval. Only one ping is outstanding at a time, so switching tabs back and forth doesn't flood the server.
Manual control
Both actions are on the hook result and on the client, and keep the same identity between renders:
reconnect()drops the current socket (if any) and connects again immediately, with the backoff reset. It also restarts aclosedclient.close(code?, reason?)closes with1000by default and stops reconnecting. See Shared clients.
Showing it in the UI
status and attempt are all you need for a connection banner:
With a module-level client, useWebSocketStatus(client) re-renders only on status changes. Use useWebSocket(client) when you also want attempt.
Heartbeats
Some proxies and load balancers silently drop connections that look idle, and a dead network link can take minutes to surface as a close. A heartbeat handles both:
messageis sent as-is on everyinterval. It doesn't go throughserializeorjson, so for a JSON protocol pass a string such as'{"type":"ping"}'.- Incoming data exactly equal to
pongis swallowed. It doesn't updatelastMessageor reachonMessage. - With
timeout, any incoming data counts as a sign of life, not only the pong. If nothing at all arrives withintimeoutms of a ping, the connection is treated as dead. The client closes it with code4000(reason'heartbeat timeout'), reports the close, and reconnects with the normal backoff. YourshouldReconnectsees code4000. - A ping also goes out right away when the browser comes back online or the tab becomes visible, so a connection that died while the laptop slept is found within
timeout, not after up tointervalmore. See Retrying right away.
The heartbeat requires your server to answer pings with some data. Browsers can't send protocol-level ping frames, so this is an application-level ping.
A dead connection is hard to produce on purpose. Chaos.stall() makes one in development: the socket stays open but goes silent, so you can watch your timeout catch it.
The send queue
send() works in any state. While the socket isn't open, messages are queued and sent in order as soon as it opens, including after a reconnect:
The queue is cleared when you call close() and when the client gives up reconnecting, so stale messages aren't sent on a later connection.
The 100-message default keeps a long outage from building up a backlog that floods the server on reconnect. A dropped message is logged via logger and calls its onDrop. Raise limit if your app sends more than that while offline and every message matters, or use queue: false for messages that are only meaningful in real time, such as presence or cursor updates.
You can also decide per message. send(message, { volatile: true }) drops a message that would be queued, and send(message, { key: 'cursor' }) keeps only the newest queued message with that key:
send() returns true only when the message went out immediately. Use it if you need to know whether a message was queued.
The queue lives in memory, and a message leaves it once it's handed to the socket, even if the connection then drops before the server gets it. For orders, reports and anything else that must arrive, use the offline outbox: it keeps messages in IndexedDB until the server acknowledges them, across reloads.
Expiring stale messages
A message that waited through a long outage can be stale by the time it goes out: a "typing…" indicator, a price quote, a request the user has given up on. maxAge drops a queued message once it has waited longer than that many milliseconds, for the whole queue or per message:
A message's own maxAge overrides queue.maxAge. Without either, a message waits as long as the queue is kept.
The age is checked when the socket opens, before the queue is sent, and whenever another message is queued. A message dropped this way calls its onDrop, like one replaced by key or evicted by limit, and a request dropped this way rejects. The fresh messages go out first, then the onDrops run, so one that sends again goes after them. Ages use Date.now(), so time the computer spends asleep counts.
Control messages first
Messages queued while offline go out in order. A control message such as "cancel" or "stop" shouldn't wait behind a backlog of input or audio frames, so pass priority:
Priority messages go out ahead of everything queued without priority, in the order they were sent. Messages sent from open listeners, such as an auth handshake, still go first. When the queue is full, ordinary messages are evicted before priority ones.
priority only reorders the queue. Once the socket is open, a message goes straight to the browser's send buffer, and nothing can overtake what's already there. To keep that buffer short, send high-rate streams with maxBuffered, which drops a message while more than that many bytes are waiting:
Debugging
Pass a logger to see attempts, delays, heartbeat timeouts and dropped messages:
See also
- Options:
reconnect,heartbeat,queue,logger - Authentication: fresh tokens on every reconnect
- Chaos testing: drop, stall and delay connections in development
- Troubleshooting