Skip to content

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

A gateway restart, two client behaviours 3 lanes over time. A gateway restart, two client behaviours gateway restarting accepting no backoff retry storm storm again jittered backoff a few a few a few time → The same total reconnections, spread over a window the server can absorb.

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.

How each failure becomes visible A grid of 4 rows by 2 columns. How each failure becomes visible failure detected by typical delay server closes cleanly close frame immediate connection reset OSError on read immediate middlebox drops the flow ping timeout ping_interval + timeout peer alive, stream stalled idle-data timeout your idle timeout Pings catch a dead peer; only an application idle timeout catches a dead stream.

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.

What every reconnect must redo 5 stages from connect to consume. What every reconnect must redo connect with backoff authenticate fresh token resubscribe since=last_seq deduplicate seq <= last_seq consume until it drops A new connection knows nothing: subscriptions and position live in the client.

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.