◈   ⌘ api · Intermediate

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.

Uncle Solieditor · voc · 06.05.2026 ·views 11
◈   Contents
  1. → What Is a WebSocket Heartbeat?
  2. → How Different Exchanges Handle Heartbeats
  3. → Implementing Heartbeat in Python
  4. → Implementing Heartbeat in JavaScript (Node.js)
  5. → Handling Binance's Dual Heartbeat Requirements
  6. → Production Reliability Patterns
  7. → Frequently Asked Questions
  8. → Conclusion

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.

What Is a WebSocket Heartbeat?

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.

How Different Exchanges Handle Heartbeats

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.

Heartbeat implementation across major exchanges
ExchangeTypeIntervalClient Action
BinanceProtocol ping frameEvery 3 minRespond with pong frame; send custom ping JSON every 30s
BybitApplication JSONEvery 20sSend {"op":"ping"} — expect {"op":"pong"}
OKXApplication JSONEvery 30sSend "ping" string — expect "pong" string
Coinbase AdvancedProtocol ping frameServer-initiatedAuto-handled by most WebSocket libs
Gate.ioApplication JSONEvery 10sSend {"channel":"spot.ping"} — expect pong channel
KuCoinApplication JSONCustom tokenServer 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.

Implementing Heartbeat in Python

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.

Implementing Heartbeat in JavaScript (Node.js)

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();

Handling Binance's Dual Heartbeat Requirements

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.

Production Reliability Patterns

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.

Frequently Asked Questions

How often should I send WebSocket ping messages to a crypto exchange?
Match the exchange's documented interval. Bybit recommends every 20 seconds, OKX every 30 seconds, Binance user streams need REST keepalive every 30 minutes. Sending too frequently can get you rate-limited; too infrequently and connections drop silently.
Does my WebSocket library handle ping/pong automatically?
For protocol-level ping frames (RFC 6455), most libraries like websockets (Python) and ws (Node.js) handle them automatically. But application-level JSON ping messages — which Bybit, OKX, Gate.io and KuCoin use — must be implemented manually in your code.
How do I know if my WebSocket connection has silently died?
The most reliable indicator is a gap between expected heartbeat responses and actual ones. Track last_pong_time and compare it against current time plus your ping interval. If the delta exceeds your threshold, assume the connection is dead and force a reconnect with ws.terminate() or ws.close().
Why does my Binance account stream stop receiving updates after an hour?
Binance listen keys expire after 60 minutes if not renewed. You must send a PUT request to /api/v3/userDataStream with your listen key every 30 minutes or less. This is separate from WebSocket ping frames and must be done via the REST API.
Can I use one WebSocket connection for multiple trading pairs?
Yes — most exchanges support multiplexed streams. On Binance you can combine streams like wss://stream.binance.com/stream?streams=btcusdt@depth/ethusdt@trade. Bybit and OKX accept multiple subscription args in a single subscribe message. This is more efficient than separate connections per pair.
What happens if I don't implement heartbeat on a trading bot?
The connection will appear active in your code but will stop receiving data after anywhere from a few minutes to an hour, depending on the exchange and your network path. Your bot will miss price updates, order fills, and liquidation events — with no error or warning. For any bot running longer than a few minutes, heartbeat is non-negotiable.

Conclusion

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.

◈   more on this topic
◉ basics Mastering the ccxt library documentation for crypto traders ⌂ exchanges Mastering the Binance CCXT Library for Crypto Traders ⌬ bots Best Crypto Trading Bots 2025: Profitable AI-Powered Strategies