# WebSocket heartbeat and reconnect in Python for trading

URL: https://fincodelabs.com/en/blog/websocket-heartbeat-reconnect · Publisher: Fincode (Fincode Yazılım ve Teknoloji A.Ş.), Istanbul, Turkey · Category: Exchange APIs · Published: 2026-11-06 · Updated: 2026-11-06

WebSocket heartbeat vs ping pong, five exchanges' documented timers against measured ones, a tested Python reconnect loop with a stale-feed watchdog and jitter.

**Short answer:** A **WebSocket heartbeat** is a periodic message showing that a connection is alive. It exists at two levels: protocol ping and pong frames from RFC 6455, which the library handles, and exchange messages such as OKX's text ping or Kraken's heartbeat channel. Neither proves that data is still moving, so a trading feed also needs a per-channel watchdog. In Python, the websockets library pings every 20 seconds by default, and its documented reconnect loop does not back off when a connection succeeds and then drops.

A feed can look healthy while your book is minutes old. This page sets documented timers against what public connections did on 5 November 2026, then tests a reconnect loop against four faults.

Tests used public endpoints or a local mock; private channels need a key and were not run. This is general engineering: no exchange is recommended, and availability depends on your jurisdiction and each platform's terms. Article 99/A of Capital Markets Law No. 6362 treats activity by foreign crypto-asset platforms directed at Turkish residents as unauthorized.

## WebSocket heartbeat vs ping pong: three layers

Protocol ping and pong test the connection, an exchange heartbeat tests the venue's application, and only a freshness check tests whether your data is still arriving.

**Three layers of liveness**

| Layer | How it works | What it misses |
| --- | --- | --- |
| Protocol ping and pong (RFC 6455, sections 5.5.2 and 5.5.3) | Control frames. A pong must carry the ping's data, and an unsolicited pong is allowed. Libraries answer for you; browser JavaScript cannot send or see them | An empty or frozen feed: the peer's WebSocket stack can keep answering |
| Exchange heartbeat | Ordinary messages, such as text `ping`, JSON `op: ping` or a heartbeat channel | A stuck channel: heartbeats can flow while one stream has stopped |
| Feed watchdog | Your code: per channel, when a message last arrived and when its event time last advanced | The wire itself, and it must tell a quiet market from a dead feed |

## WebSocket heartbeat interval by exchange: documents and measurements

Venues document different timers, and on 5 November 2026 several connections did not behave as documented, so design for the shorter of the two and test your own.

**Documents read 5 November 2026 (alphabetical). Measured 19:44 to 19:57 UTC that day from one Windows client: Python 3.11.3, websockets 13.1 with client keepalive off, public endpoints, no keys**

| Venue | Documented | Measured |
| --- | --- | --- |
| Binance Spot streams | Server ping frame every 20 seconds; no pong within a minute means disconnect | Testnet pings 20.0 seconds apart with a timestamp payload; idle connection open at 150 seconds |
| Bybit V5 | Client sends `{"op": "ping"}`, recommended every 20 seconds; cut off after 10 minutes without traffic | With no pings, whether unsubscribed (mainnet and testnet) or subscribed to a quiet testnet symbol, closed at 60.0 seconds with no close frame. With `op: ping` every 20 seconds, open at 690 seconds. No server pings |
| Coinbase Advanced Trade | `heartbeats` channel every second; disconnected with no subscribe within 5 seconds; most channels close within 60 to 90 seconds when idle | Heartbeats 1.0 second apart. With no subscribe, and on the quiet `status` channel, still open at 170 seconds; server pings 20.0 seconds apart |
| Kraken v2 | Heartbeat about once a second when there are no other updates, after subscribing to any channel; optional JSON ping | Subscribed to `instrument`: 110 heartbeats, 1.0 second apart on average. With no subscription, closed at 56.1 and 56.2 seconds with no close frame |
| OKX | Client sends text `ping` on a timer under 30 seconds; connection breaks if no subscription or no data for 30 seconds | Closed at 30.0 seconds with code 4004 and reason `No data received in 30s.` on the production and demo public hosts, ports 443 and 8443 (on 8443, notice 64010 said the port will be retired). Text ping every 10 seconds: open at 170 seconds |

A protocol ping every 10 seconds, with no other traffic, kept Bybit, Kraken and OKX open as their application pings did (690 seconds on the Bybit testnet, 170 on the others), but no document promises that, so send what each venue specifies. A pong check looking for `op: pong` fails on Bybit's public spot and linear streams, whose replies carried `"op": "ping", "ret_msg": "pong"`. A close with no frame logs nothing, so record idle time yourself. Binance's own limits and rotation are in the [Binance WebSocket guide](https://fincodelabs.com/en/blog/binance-websocket-api).

## Python websockets: ping defaults and reconnect, tested

The library handles protocol keepalive, but its reconnect loop only backs off after failed attempts, so a server that accepts and then drops gets hammered.

- **Defaults.** Installed 13.1 and current 17.4 both use `ping_interval=20`, `ping_timeout=20` and `close_timeout=10`. A black-holed connection can therefore take up to about 50 seconds to raise an error; in the drill below, timers of 1, 1 and 2 seconds took 3.6 to 3.7.
- **Import path.** On 13.1, `websockets.connect` is the legacy client; `websockets.asyncio.client` works on both versions.
- **A conflict in the documents.** On 5 November 2026 the client reference said backoff is capped at 60 seconds, while the environment variables page said 90. The source and `backoff()` on both versions gave a random first delay of up to 5 seconds, then 3.1 rising to 90.
- **No backoff after success.** The FAQ's `async for ws in connect(...)` with `except ConnectionClosed: continue` resets its delay whenever a handshake succeeds: against a local server that accepted and dropped at once, it made 606 to 1,089 connections in 5 seconds across twelve runs on both versions.
- **Cut handshakes.** A server closing TCP before answering raised `EOFError` on 13.1 and `InvalidMessage` on 17.4, neither a `ConnectionClosed` nor an `OSError`, so catch them.
- **Refusals.** HTTP 403 and 451 raised `InvalidStatus` at once, while 503 kept retrying. If a venue refuses you with 403 or 451 because of location, the service is not available there: stop, and do not route around it.

## Python WebSocket reconnect on error: a tested loop

This loop resubscribes and resyncs on every connection, watches each channel for silence and frozen event time, sends venue pings, and waits with full jitter unless the last session was healthy.

```python
import asyncio, json, random, time
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed, InvalidMessage, InvalidStatus

class FeedStale(Exception):
    pass

class Watchdog:
    def __init__(self, max_silence, max_frozen, expected=()):
        now = time.monotonic()
        self.max_silence, self.max_frozen = max_silence, max_frozen
        self.seen = {c: {"at": now, "moved": now, "event": 0} for c in expected}

    def saw(self, channel, event_time):
        now = time.monotonic()
        s = self.seen.setdefault(channel, {"at": now, "moved": now, "event": event_time})
        s["at"] = now
        if event_time > s["event"]:
            s["event"], s["moved"] = event_time, now

    def problem(self):
        now = time.monotonic()
        for channel, s in self.seen.items():
            if now - s["at"] > self.max_silence:
                return channel, f"silent {now - s['at']:.1f} s"
            if now - s["moved"] > self.max_frozen:
                return channel, f"event time frozen {now - s['moved']:.1f} s"

    def forgive(self, channel):          # a quiet market, not a dead feed
        self.seen[channel]["at"] = self.seen[channel]["moved"] = time.monotonic()

async def run_feed(url, subscribe, parse, resync, channels=(), is_dead=None, app_ping=None, on_end=print,
                   ping_interval=20, ping_timeout=20, max_silence=30, max_frozen=30,
                   base=1.0, cap=60.0, stable_after=60.0):
    failures = 0
    while True:
        opened, beat = time.monotonic(), None
        try:
            async with connect(url, ping_interval=ping_interval, ping_timeout=ping_timeout,
                               open_timeout=10, close_timeout=2) as ws:
                await subscribe(ws)
                await resync()                   # snapshot, open orders, fills
                if app_ping:                     # (payload, seconds), such as ("ping", 25)
                    beat = asyncio.create_task(send_every(ws, *app_ping))
                dog = Watchdog(max_silence, max_frozen, channels)
                while True:
                    try:
                        raw = await asyncio.wait_for(ws.recv(), 1)
                    except asyncio.TimeoutError:
                        raw = None
                    seen = parse(raw) if raw else None   # (channel, event time) or None
                    if seen:
                        dog.saw(*seen)
                    found = dog.problem()
                    if found:
                        if is_dead is None or await is_dead(found[0]):  # ask REST first
                            raise FeedStale(f"{found[0]}: {found[1]}")
                        dog.forgive(found[0])
        except InvalidStatus as err:
            if err.response.status_code not in (500, 502, 503, 504):
                raise                             # 403, 451: stop, do not retry
            reason = f"HTTP {err.response.status_code}"
        except (ConnectionClosed, InvalidMessage, EOFError,   # the last two: a cut handshake
                OSError, asyncio.TimeoutError, FeedStale) as err:
            reason = f"{type(err).__name__}: {err}"
        finally:
            if beat:
                beat.cancel()
        # a successful handshake proves nothing; a healthy session does
        failures = 0 if time.monotonic() - opened >= stable_after else failures + 1
        delay = random.uniform(0, min(cap, base * 2 ** min(failures, 20)))   # full jitter; 2 ** 1024 overflows a float
        on_end(reason, delay)
        await asyncio.sleep(delay)

async def send_every(ws, payload, seconds):
    while True:
        await asyncio.sleep(seconds)
        await ws.send(payload)
```

`parse` returns the channel and exchange event time as a number, or `None` for pongs and acknowledgments. Event time is compared with itself, so clock offset does no harm; a feed that advances but lags needs a clock check too. Pass `is_dead` to ask REST whether anything traded before reconnecting, because a quiet symbol is not a dead feed; answering no, it caused no reconnect in 16 seconds of silence. List expected channels in `channels`: without it, a mock silent from the start went unflagged; with it, flagged at about 5 seconds.

## WebSocket heartbeat example: a mock server that fails four ways

A local mock server produces each fault on demand; live endpoints will not.

```python
import asyncio, json, time
from websockets.asyncio.server import serve
from websockets.exceptions import ConnectionClosed

async def mock(ws):
    """Five good messages, then the fault named by the URL path."""
    fault = ws.request.path.strip("/")
    if fault == "accept-then-drop":
        return ws.transport.abort()
    await ws.recv()                                  # the subscribe message
    frozen = int(time.time() * 1000)
    for n in range(1, 10 ** 6):
        if n > 5 and fault == "drop":
            return ws.transport.abort()              # TCP gone, no close frame
        if n > 5 and fault == "blackhole":
            ws.transport.pause_reading()             # no data and no pongs
            await asyncio.sleep(30)
            return ws.transport.abort()
        if n > 5 and fault == "silent":
            return await ws.wait_closed()            # no data, pings still answered
        event = frozen if n > 5 and fault == "stale" else int(time.time() * 1000)
        try:
            await ws.send(json.dumps({"n": n, "E": event}))
        except ConnectionClosed:
            return
        await asyncio.sleep(0.1)

async def main():
    async with serve(mock, "localhost", 8765):
        await asyncio.Future()

asyncio.run(main())
```

A harness ran each fault with ping interval and timeout of 1 second, 5 for silence and 6 for frozen event time, timing from the fifth message to the session's end:

```
websockets 13.1
drop       0.1 s  ConnectionClosedError: no close frame received or sent | resynced: True
blackhole  3.6 s  ConnectionClosedError: sent 1011 (internal error) keepalive ping timeout; no close frame received | resynced: True
silent     5.0 s  FeedStale: trades: silent 5.0 s | resynced: True
stale      6.1 s  FeedStale: trades: event time frozen 6.1 s | resynced: True
documented async-for loop: 1089 connections in 5 s
run_feed: 3 connections in 5 s
```

Each layer caught a fault the others missed: keepalive caught the black hole but passed the silent feed, whose pings were still answered, and the stale feed passed keepalive and the silence check because messages kept flowing. Websockets 17.4 caught the same faults the same way (both on Python 3.11.3).

## Why reconnect jitter matters: connection limits

Restarts that reconnect in step can reach a connection limit and turn a short outage into a ban or a longer one.

**Connection limits, official pages read 5 November 2026 (alphabetical)**

| Venue | Limit |
| --- | --- |
| Binance Spot streams | 300 connection attempts per 5 minutes per IP |
| Bybit V5 | No more than 500 connections in 5 minutes per WebSocket domain |
| OKX | 3 connection requests per second per IP |

> **Hypothetical example.** Fifty processes behind one IP lose their feed and retry every second: 50 attempts a second reach a limit of 300 in 6 seconds. With full jitter, each waits a random time up to a doubling ceiling, so attempts spread out.

The values in `run_feed` are illustrative, not exchange figures.

## What to resync after a WebSocket reconnect

Treat everything derived from the old connection as unknown until it is rebuilt, because the stream does not replay what you missed.

The `subscribe` hook resends channels, and `resync` rebuilds state before anything trusts it: a fresh snapshot for books, as the [local order book guide](https://fincodelabs.com/en/blog/websocket-order-book) describes, and open orders and fills for anything that trades, which is the job of [position reconciliation](https://fincodelabs.com/en/blog/position-reconciliation). Send no new orders until it returns. Wider channel design is in [crypto exchange API integration](https://fincodelabs.com/en/blog/crypto-exchange-api-integration). For integration work in general, see the [exchange API integration](https://fincodelabs.com/en/services/exchange-api-integration) page; naming Binance, Bybit, Coinbase, Kraken or OKX here does not mean Fincode integrates them or has built on them. Automated trading can lose money, and tests show how software behaves, not future results.

## Frequently asked questions

### What is the difference between a WebSocket heartbeat and ping pong?

Ping and pong are protocol control frames that libraries answer automatically. A heartbeat usually means an application message your code can see, such as OKX's text ping.

### What WebSocket heartbeat interval should I use?

Use what the venue documents: every 20 seconds on Bybit and under 30 seconds on OKX, as read on 5 November 2026. Idle connections closed sooner than some documents state, so test your own.

### How do I reconnect a Python WebSocket on error?

Loop over the connection, catch ConnectionClosed, InvalidMessage, EOFError, OSError and timeouts, and wait with a jittered, growing delay. Reset it only after a healthy session, and stop on HTTP 403 or 451.

### Does a WebSocket ping prove my market data is current?

No. In the drill, a silent feed kept answering pings. Check per channel when a message last arrived and when its event time last advanced.

## Sources

- [RFC 6455: The WebSocket Protocol, sections 5.5.2 and 5.5.3](https://www.rfc-editor.org/rfc/rfc6455.txt)
- [websockets documentation: asyncio client (connect, reconnect_delays, process_exception)](https://websockets.readthedocs.io/en/stable/reference/asyncio/client.html)
- [websockets documentation: Keepalive and latency](https://websockets.readthedocs.io/en/stable/topics/keepalive.html)
- [websockets documentation: Environment variables (backoff defaults)](https://websockets.readthedocs.io/en/stable/reference/variables.html)
- [websockets documentation: FAQ, client (reconnect loop)](https://websockets.readthedocs.io/en/stable/faq/client.html)
- [Binance Spot API documentation: WebSocket Streams](https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-streams.md)
- [Bybit V5 API: WebSocket Connect](https://bybit-exchange.github.io/docs/v5/ws/connect)
- [OKX API v5 documentation: WebSocket connect and keep-alive](https://www.okx.com/docs-v5/en/)
- [Coinbase Developer Platform: Advanced Trade WebSocket overview](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-overview)
- [Coinbase Developer Platform: Advanced Trade WebSocket endpoints (channel list and idle-close note)](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-endpoints)
- [Kraken API: WebSocket v2 heartbeat](https://docs.kraken.com/exchange/api-reference/spot-websocket-v2/heartbeat)
- [Kraken API: WebSocket v2 ping](https://docs.kraken.com/exchange/api-reference/spot-websocket-v2/ping)
- [WebSocket.org: WebSocket Heartbeat guide (16 March 2026)](https://websocket.org/guides/heartbeat/)
- [Capital Markets Law No. 6362, consolidated text, Article 99/A (Turkish)](https://www.mevzuat.gov.tr/mevzuatmetin/1.5.6362.pdf)

> This guide is general information about software and markets, not investment, legal or tax advice. Automated trading can lose money, past or backtested performance does not guarantee future results, and the rules that apply depend on your jurisdiction and your exchange's terms. Sources, checks and corrections: see the [editorial policy](https://fincodelabs.com/en/editorial-policy).

---

Published by Fincode (Fincode Yazılım ve Teknoloji A.Ş.), a software company in Istanbul, Turkey: https://fincodelabs.com/en/blog/websocket-heartbeat-reconnect

Cite as: Fincode, "WebSocket heartbeat and reconnect in Python for trading", updated November 6, 2026, https://fincodelabs.com/en/blog/websocket-heartbeat-reconnect
