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.
| 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.
| 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.
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=20andclose_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.connectis the legacy client;websockets.asyncio.clientworks 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(...)withexcept ConnectionClosed: continueresets 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
EOFErroron 13.1 andInvalidMessageon 17.4, neither aConnectionClosednor anOSError, so catch them. - Refusals. HTTP 403 and 451 raised
InvalidStatusat 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.
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.
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 sEach 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.
| 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 describes, and open orders and fills for anything that trades, which is the job of position reconciliation. Send no new orders until it returns. Wider channel design is in crypto exchange API integration. For integration work in general, see the 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 (opens in a new tab)
- websockets documentation: asyncio client (connect, reconnect_delays, process_exception) (opens in a new tab)
- websockets documentation: Keepalive and latency (opens in a new tab)
- websockets documentation: Environment variables (backoff defaults) (opens in a new tab)
- websockets documentation: FAQ, client (reconnect loop) (opens in a new tab)
- Binance Spot API documentation: WebSocket Streams (opens in a new tab)
- Bybit V5 API: WebSocket Connect (opens in a new tab)
- OKX API v5 documentation: WebSocket connect and keep-alive (opens in a new tab)
- Coinbase Developer Platform: Advanced Trade WebSocket overview (opens in a new tab)
- Coinbase Developer Platform: Advanced Trade WebSocket endpoints (channel list and idle-close note) (opens in a new tab)
- Kraken API: WebSocket v2 heartbeat (opens in a new tab)
- Kraken API: WebSocket v2 ping (opens in a new tab)
- WebSocket.org: WebSocket Heartbeat guide (16 March 2026) (opens in a new tab)
- Capital Markets Law No. 6362, consolidated text, Article 99/A (Turkish) (opens in a new tab)
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.