WebSocket Heartbeat: Keep Your Crypto Exchange Connection Alive
Learn how WebSocket heartbeats work on crypto exchanges, why dropped connections cost you trades, and how to implement robust ping/pong handling in Python and JavaScript.
Learn how WebSocket heartbeats work on crypto exchanges, why dropped connections cost you trades, and how to implement robust ping/pong handling in Python and JavaScript.
If you've ever had a trading bot suddenly stop receiving price updates mid-session — or watched an order book freeze while the market was moving — you've experienced a dead WebSocket connection. The fix is almost always the same: proper heartbeat handling. This is one of those things that separates bots that run reliably for weeks from ones that silently die at 3am and miss a major move.
WebSocket is a persistent, full-duplex communication protocol. Unlike REST, where you request data and get a response, WebSocket keeps a single connection open and streams data continuously. Binance, Bybit, OKX, and most other major exchanges use WebSocket for real-time market data — order books, trades, klines, and account updates.
The problem: TCP connections don't stay alive forever on their own. Firewalls, NAT routers, and load balancers will silently drop connections that appear idle. Even without those, the exchange server or your client might have a timeout policy. From the outside, your connection looks alive. Internally, it's dead. You're no longer receiving data, but you won't know until it's too late.
A heartbeat is a mechanism to prove the connection is still alive. One side sends a small ping message. The other responds with pong. If the pong doesn't arrive within a timeout window, you know the connection is dead and can reconnect. Every serious exchange WebSocket API implements this — they just differ in how.
Critical: A silent dead connection is worse than an obvious crash. Your code thinks it's connected and receiving data. It's not. Always implement heartbeat monitoring.
Exchanges implement heartbeats in two main ways: protocol-level ping/pong frames (part of the WebSocket spec itself) and application-level ping messages sent as JSON over the data channel. You need to handle both correctly.
| Exchange | Type | Interval | Client Action |
|---|---|---|---|
| Binance | Protocol ping frame | Every 3 min | Respond with pong frame; send custom ping JSON every 30s |
| Bybit | Application JSON | Every 20s | Send {"op":"ping"} — expect {"op":"pong"} |
| OKX | Application JSON | Every 30s | Send "ping" string — expect "pong" string |
| Coinbase Advanced | Protocol ping frame | Server-initiated | Auto-handled by most WebSocket libs |
| Gate.io | Application JSON | Every 10s | Send {"channel":"spot.ping"} — expect pong channel |
| KuCoin | Application JSON | Custom token | Server sends pingInterval in connect response |
Binance is slightly unusual — their WebSocket server sends protocol-level pings, and your client must respond with pong frames. Most WebSocket libraries handle this automatically. But Binance also requires you to actively send a ping at the application level every 30 seconds to keep the listen key alive for user data streams. Bybit and OKX are more straightforward: send a JSON ping, get a JSON pong back.
The most reliable pattern in Python uses asyncio with a separate coroutine running the heartbeat loop concurrently with your message handler. Here's a production-ready implementation for Bybit:
import asyncio
import json
import time
import websockets
from websockets.exceptions import ConnectionClosed
BYBIT_WS_URL = "wss://stream.bybit.com/v5/public/spot"
PING_INTERVAL = 20 # seconds
PONG_TIMEOUT = 10 # seconds
class BybitWebSocket:
def __init__(self):
self.ws = None
self.last_pong = time.time()
self.running = False
async def send_ping(self):
"""Send application-level ping to Bybit."""
await self.ws.send(json.dumps({"op": "ping"}))
async def heartbeat_loop(self):
"""Send ping every PING_INTERVAL seconds and check pong timeout."""
while self.running:
await asyncio.sleep(PING_INTERVAL)
try:
await self.send_ping()
# Check if pong came back within timeout
await asyncio.sleep(PONG_TIMEOUT)
if time.time() - self.last_pong > PING_INTERVAL + PONG_TIMEOUT:
print("Pong timeout — reconnecting")
await self.ws.close()
return
except ConnectionClosed:
return
async def message_handler(self):
"""Process incoming messages and track pong responses."""
async for message in self.ws:
data = json.loads(message)
# Bybit pong response
if data.get("op") == "pong":
self.last_pong = time.time()
continue
# Handle market data
await self.process_message(data)
async def process_message(self, data):
"""Your actual message processing logic."""
print(f"Received: {data}")
async def subscribe(self):
"""Subscribe to orderbook channel."""
sub_msg = {
"op": "subscribe",
"args": ["orderbook.1.BTCUSDT"]
}
await self.ws.send(json.dumps(sub_msg))
async def connect(self):
self.running = True
while self.running:
try:
print("Connecting to Bybit...")
async with websockets.connect(BYBIT_WS_URL) as ws:
self.ws = ws
self.last_pong = time.time()
await self.subscribe()
# Run heartbeat and message handler concurrently
await asyncio.gather(
self.heartbeat_loop(),
self.message_handler()
)
except (ConnectionClosed, OSError) as e:
print(f"Connection lost: {e}. Reconnecting in 5s...")
await asyncio.sleep(5)
async def main():
client = BybitWebSocket()
await client.connect()
asyncio.run(main())
The key here is running heartbeat_loop and message_handler as concurrent coroutines with asyncio.gather. If either one exits (heartbeat timeout or connection drop), gather cancels the other and the outer while loop triggers reconnection.
For JavaScript bots, here's the equivalent pattern for OKX, which expects a plain 'ping' string and responds with 'pong':
const WebSocket = require('ws');
const OKX_WS_URL = 'wss://ws.okx.com:8443/ws/v5/public';
const PING_INTERVAL_MS = 25000; // 25 seconds (OKX recommends every 30s)
const PONG_TIMEOUT_MS = 10000;
class OKXWebSocket {
constructor() {
this.ws = null;
this.pingTimer = null;
this.pongTimer = null;
this.reconnectDelay = 5000;
}
connect() {
console.log('Connecting to OKX...');
this.ws = new WebSocket(OKX_WS_URL);
this.ws.on('open', () => {
console.log('Connected');
this.subscribe();
this.startHeartbeat();
});
this.ws.on('message', (data) => {
const msg = data.toString();
// OKX responds with plain string 'pong'
if (msg === 'pong') {
clearTimeout(this.pongTimer);
return;
}
try {
this.processMessage(JSON.parse(msg));
} catch (e) {
console.error('Parse error:', e);
}
});
this.ws.on('close', () => {
console.log('Connection closed — reconnecting...');
this.cleanup();
setTimeout(() => this.connect(), this.reconnectDelay);
});
this.ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
this.ws.terminate();
});
}
startHeartbeat() {
this.pingTimer = setInterval(() => {
if (this.ws.readyState !== WebSocket.OPEN) return;
this.ws.send('ping');
// If pong doesn't come back in time, force reconnect
this.pongTimer = setTimeout(() => {
console.log('Pong timeout — forcing reconnect');
this.ws.terminate();
}, PONG_TIMEOUT_MS);
}, PING_INTERVAL_MS);
}
subscribe() {
const subMsg = {
op: 'subscribe',
args: [{ channel: 'books', instId: 'BTC-USDT' }]
};
this.ws.send(JSON.stringify(subMsg));
}
processMessage(data) {
// Your market data handling logic
console.log('Data:', JSON.stringify(data).slice(0, 100));
}
cleanup() {
clearInterval(this.pingTimer);
clearTimeout(this.pongTimer);
}
}
const client = new OKXWebSocket();
client.connect();
Binance deserves special mention because it has two separate heartbeat concerns that confuse a lot of developers. Market data streams (like wss://stream.binance.com:9443/ws/btcusdt@depth) use protocol-level ping frames — your WebSocket library handles these automatically with no code required. But user data streams are different.
For account updates on Binance, you first call the REST API to get a listen key, then connect to that listen key's WebSocket stream. The listen key expires after 60 minutes unless you send a keepalive request via REST every 30 minutes. This is completely separate from the WebSocket ping/pong mechanism.
import asyncio
import aiohttp
import websockets
import json
import os
BINANCE_API_KEY = os.environ['BINANCE_API_KEY']
BINANCE_BASE_URL = 'https://api.binance.com'
async def get_listen_key(session):
"""Get a fresh listen key for user data stream."""
headers = {'X-MBX-APIKEY': BINANCE_API_KEY}
async with session.post(
f'{BINANCE_BASE_URL}/api/v3/userDataStream',
headers=headers
) as resp:
data = await resp.json()
return data['listenKey']
async def keepalive_listen_key(session, listen_key):
"""Must be called every 30 minutes or listen key expires."""
headers = {'X-MBX-APIKEY': BINANCE_API_KEY}
await session.put(
f'{BINANCE_BASE_URL}/api/v3/userDataStream',
headers=headers,
params={'listenKey': listen_key}
)
print('Listen key renewed')
async def keepalive_loop(session, listen_key):
"""Renew listen key every 25 minutes (buffer before 30min expiry)."""
while True:
await asyncio.sleep(25 * 60)
await keepalive_listen_key(session, listen_key)
async def stream_user_data():
async with aiohttp.ClientSession() as session:
listen_key = await get_listen_key(session)
ws_url = f'wss://stream.binance.com:9443/ws/{listen_key}'
print(f'Connecting with key: {listen_key[:8]}...')
async with websockets.connect(ws_url) as ws:
# Run keepalive and message reading concurrently
async def read_messages():
async for msg in ws:
data = json.loads(msg)
print(f"Account event: {data.get('e')} — {data}")
await asyncio.gather(
keepalive_loop(session, listen_key),
read_messages()
)
asyncio.run(stream_user_data())
On Binance user data streams: protocol pings are auto-handled by your library, but listen key keepalive via REST is your responsibility. Miss it and your account stream silently dies after 60 minutes.
Heartbeat is just one layer of a reliable WebSocket setup. Once you have ping/pong working, there are a few more patterns worth implementing in any production trading system.
Platforms like VoiceOfChain that deliver real-time trading signals are dealing with these exact reliability challenges at scale — maintaining persistent WebSocket connections to multiple exchanges simultaneously while ensuring signal delivery is never interrupted by a dropped connection. The same patterns apply whether you're running a single bot or a multi-exchange signal infrastructure.
WebSocket heartbeats are not optional infrastructure — they're the difference between a bot that trades reliably and one that silently fails during the moves that matter most. The implementation is maybe 30 lines of code, but understanding why each exchange does it differently (Binance's dual-layer approach, OKX's plain string ping, KuCoin's dynamic interval from the connect response) saves you hours of debugging mysterious data gaps. Get the heartbeat right, layer on reconnection logic and sequence tracking, and you have a foundation that can run unattended for weeks.