Reconnecting WebSocket Clients with Backoff¶
A WebSocket connection is not a request; it is a relationship that outlives deploys, load-balancer timeouts, laptop sleeps and network changes. Every long-lived client therefore needs a reconnect loop, and the naive version — while True: try: connect… except: pass — is how a fleet takes down its own gateway. When the gateway restarts, ten thousand clients notice within milliseconds and reconnect immediately, all together, repeatedly, with no delay between attempts. The gateway that was coming back up now faces a synchronised flood. A correct client reconnects with jittered exponential backoff, resubscribes and resumes from where it stopped, distinguishes a dead connection from an idle one, and knows when to give up. This guide builds that client with the websockets library, verified against a server that deliberately drops its first connections.
Prerequisites¶
- Python 3.11+ and
websockets(pip install websockets), using itswebsockets.asyncioAPI; the patterns apply to any WebSocket client. - Stream fundamentals from WebSocket & Real-Time Streams and heartbeats from tuning WebSocket ping/pong heartbeats.
- Backoff from exponential backoff with jitter in asyncio.
1. Reconnect with jittered exponential backoff¶
The loop has three jobs: connect, consume until the connection ends, and wait before trying again. The waiting is what protects the server, and the jitter is what keeps a fleet from retrying in lockstep.
# pip install websockets
import asyncio
import random
import time
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed
async def consume_forever(uri: str, on_message, stop: asyncio.Event,
rng: random.Random | None = None) -> None:
rng = rng or random.Random()
attempt = 0
while not stop.is_set():
try:
async with connect(uri, open_timeout=5, ping_interval=20, ping_timeout=20) as ws:
attempt = 0 # healthy connection: reset backoff
async for message in ws:
on_message(message)
except (ConnectionClosed, OSError, TimeoutError) as exc:
attempt += 1
delay = min(30.0, 0.5 * 2 ** attempt) * rng.uniform(0.5, 1.5) # jittered
on_message(f"reconnect in {delay:.2f}s after {type(exc).__name__}")
await asyncio.sleep(delay)
Two details are easy to miss. attempt resets only after a connection has been established, not after each attempt, so a server that accepts and immediately closes still produces growing delays rather than a tight loop. And the jitter multiplies the delay by a random factor around one, which spreads a fleet's attempts across the whole window — full jitter, as described in the backoff guide, spreads them even further.
Verify: against a server that closes the first two connections, the client reconnects twice with increasing delays and then stays connected.
2. Resubscribe and resume after every reconnect¶
A new connection is a blank slate: the server knows nothing about the subscriptions or cursor position of the old one. Everything the session needs must be re-established inside the loop, and the client must remember where the stream stopped.
import asyncio
import json
import random
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed
class ResumableFeed:
def __init__(self, uri: str, symbols: list[str]) -> None:
self.uri = uri
self.symbols = symbols
self.last_seq: int | None = None # resume token
self.messages: list[dict] = []
self.reconnects = 0
async def _session(self) -> None:
async with connect(self.uri, open_timeout=5, ping_interval=20) as ws:
await ws.send(json.dumps({"op": "subscribe", "symbols": self.symbols,
"since": self.last_seq})) # resubscribe + resume
async for raw in ws:
message = json.loads(raw)
if message.get("seq") is not None:
if self.last_seq is not None and message["seq"] <= self.last_seq:
continue # duplicate after resume
self.last_seq = message["seq"]
self.messages.append(message)
async def run(self, stop: asyncio.Event, rng: random.Random | None = None) -> None:
rng = rng or random.Random()
attempt = 0
while not stop.is_set():
try:
await self._session()
attempt = 0
except (ConnectionClosed, OSError, TimeoutError):
self.reconnects += 1
attempt += 1
await asyncio.sleep(min(30.0, 0.5 * 2 ** attempt) * rng.uniform(0.5, 1.5))
Sending since=last_seq asks the server to replay from the last message the client processed. Servers that cannot replay will send only live data, which makes the gap explicit rather than silent — and the deduplication check means a server that replays generously cannot deliver the same message twice. Where the protocol has no sequence numbers, the client must either tolerate gaps or reconcile through a separate snapshot endpoint after reconnecting.
Verify: after a forced disconnect, the subscribe frame carries the last sequence number, and no message appears twice in messages.
3. Detect connections that are dead but not closed¶
The worst failure is not a closed connection; it is one that stays open while nothing arrives, because a middlebox dropped the flow or the peer's host vanished. Without heartbeats the client waits forever. websockets sends protocol-level pings for you; a quiet-stream watchdog covers cases where the peer answers pings but stops sending data.
import asyncio
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed
async def session_with_watchdog(uri: str, on_message, idle_timeout: float = 30.0) -> None:
async with connect(uri, ping_interval=20, ping_timeout=20) as ws:
while True:
try:
async with asyncio.timeout(idle_timeout):
message = await ws.recv() # nothing for idle_timeout = suspect
except TimeoutError:
await ws.close(code=1011, reason="no data within idle timeout")
raise ConnectionClosed(None, None) # let the reconnect loop handle it
on_message(message)
ping_interval and ping_timeout detect a peer that stopped responding at all; the idle timeout detects one that is technically alive but has stopped delivering. Set the idle timeout well above the slowest expected gap between messages — for a feed with a server heartbeat every ten seconds, thirty is reasonable — or the client will reconnect during quiet periods and add load for no reason.
Verify: with a server that stops sending but keeps the socket open, the client closes and reconnects after the idle timeout rather than hanging.
4. Watch the loop against a server that drops connections¶
Putting it together against a server that closes its first two connections shows the sequence: connect, receive, lose the connection, wait, reconnect, and finally stream normally.
import asyncio
import random
import time
from websockets.asyncio.client import connect
from websockets.asyncio.server import serve
from websockets.exceptions import ConnectionClosed
accepted = {"n": 0}
async def flaky_handler(ws) -> None:
accepted["n"] += 1
await ws.send(f"welcome #{accepted['n']}")
if accepted["n"] < 3: # drop the first two clients
await asyncio.sleep(0.05)
await ws.close(code=1011, reason="server restart")
return
for i in range(3):
await ws.send(f"tick {i}")
await asyncio.sleep(0.02)
await asyncio.sleep(0.2)
async def main() -> None:
async with serve(flaky_handler, "127.0.0.1", 0) as server:
port = server.sockets[0].getsockname()[1]
events: list[str] = []
stop = asyncio.Event()
def record(message: str) -> None:
events.append(message)
if len(events) >= 8:
stop.set()
started = time.perf_counter()
task = asyncio.create_task(
consume_forever(f"ws://127.0.0.1:{port}", record, stop, random.Random(1)))
await stop.wait()
task.cancel()
await asyncio.gather(task, return_exceptions=True)
print(f"{len(events)} events in {time.perf_counter() - started:.2f}s; "
f"server accepted {accepted['n']} connections")
for event in events:
print(" ", event)
asyncio.run(main())
The client received welcome #1, backed off, reconnected, received welcome #2, backed off again with a longer delay, and then streamed the ticks from the third connection. The delays grew because attempt incremented on each closed connection, and the server saw three connections rather than a flood.
Verify: the event list shows two reconnect in … lines with increasing delays, and the server's connection count matches the number of client attempts.
5. Decide when to stop trying, and make it visible¶
Infinite reconnection is right for a desktop client and wrong for a batch job with a deadline. Bound the loop by attempts, by total time, or by a circuit breaker, and export what the loop is doing.
import asyncio
import random
class ReconnectPolicy:
def __init__(self, max_attempts: int | None = None, max_total: float | None = None,
cap: float = 30.0, base: float = 0.5) -> None:
self.max_attempts, self.max_total = max_attempts, max_total
self.cap, self.base = cap, base
self.attempt = 0
self.started = None
self.reconnects = 0
self.last_error: str | None = None
def on_connected(self) -> None:
self.attempt = 0
self.started = None
def next_delay(self, loop_time: float, exc: BaseException,
rng: random.Random) -> float | None:
"""Seconds to wait, or None when the client should give up."""
self.attempt += 1
self.reconnects += 1
self.last_error = type(exc).__name__
if self.started is None:
self.started = loop_time
if self.max_attempts is not None and self.attempt > self.max_attempts:
return None
if self.max_total is not None and loop_time - self.started > self.max_total:
return None
return min(self.cap, self.base * 2 ** self.attempt) * rng.uniform(0.5, 1.5)
def snapshot(self) -> dict:
return {"connected": self.attempt == 0, "consecutive_failures": self.attempt,
"reconnects_total": self.reconnects, "last_error": self.last_error}
async def main() -> None:
policy = ReconnectPolicy(max_attempts=3, base=0.01)
loop = asyncio.get_running_loop()
rng = random.Random(2)
while (delay := policy.next_delay(loop.time(), OSError("refused"), rng)) is not None:
await asyncio.sleep(delay)
print("gave up:", policy.snapshot())
asyncio.run(main())
The three numbers to export are connection state, consecutive failures and total reconnects. A fleet-wide spike in reconnects_total is the signal that the server dropped everyone, not that individual clients are flaky; a single client stuck with rising consecutive_failures is a client-side or network problem. Alert on the fleet ratio, not on individual reconnects, which are normal.
Verify: the policy gives up after the configured attempts and reports the failure count and last error.
Verification¶
A reconnecting client is production-ready when:
- Backoff is exponential and jittered, capped, and reset only after a connection is established.
- Sessions rebuild their state: every reconnect resubscribes and resumes from the last processed position, with duplicate suppression.
- Dead connections are detected: protocol pings plus an idle-data timeout end connections that stopped delivering.
- Give-up rules exist where they belong: batch clients bound attempts or total time; long-lived clients reconnect indefinitely with a cap.
- The loop is observable: connection state, consecutive failures and total reconnects are exported, with fleet-level alerting.
Pitfalls & edge cases¶
- Resetting backoff on connect rather than on a healthy session. A server that accepts then immediately closes keeps the delay at its minimum; reset after successful use, or after a first message.
- Reconnect loops without cancellation. The loop must exit on a stop event or cancellation, or shutdown hangs while it waits out a 30-second backoff.
- Subscribing outside the loop. Subscriptions sent once, before the loop, are lost on the first reconnect.
- Unbounded client-side buffers. Messages queued while disconnected grow without limit; bound the buffer and decide whether to drop or to fail.
- Thundering herds on deploy. Even with per-attempt jitter, clients that all disconnect at once retry in a wave. Add a small random delay before the first attempt after a disconnect, and stagger server restarts.
Frequently Asked Questions¶
How should a WebSocket client reconnect after a disconnect?
In a loop that connects, consumes until the connection ends, and then waits before trying again. The wait should be exponential with jitter and a cap, and the attempt counter should reset only after a connection has been used successfully, so a server that accepts and immediately closes still backs off.
How do I resume a WebSocket stream after reconnecting?
Keep the identifier of the last message the client processed and send it when resubscribing, so the server can replay from that point. Deduplicate by sequence number in case the server replays more than needed, and reconcile through a snapshot endpoint when the protocol has no resume support.
How do I detect a WebSocket connection that is open but dead?
Use protocol-level pings — in the websockets library, ping_interval and ping_timeout — to detect a peer that stops responding, and add an application-level idle timeout that ends the connection when no data arrives for longer than the expected gap between messages, including server heartbeats.
Should a WebSocket client ever stop reconnecting?
It depends on the client. Long-lived clients such as UIs and agents should reconnect indefinitely with a capped, jittered delay. Batch jobs and request-scoped clients should bound attempts or total reconnect time and fail, so the work does not hang forever behind an unavailable server.
Related¶
- WebSocket & Real-Time Streams — up to the topic overview for connection lifecycle and backpressure.
- Tuning WebSocket ping/pong heartbeats — choosing the intervals this loop relies on.
- Network I/O & Protocol Handling — the section overview for protocols and transports.