Skip to content
FactorQX
intermediatebroker-apiwebsocketsstreaming

Streaming Market Data over WebSockets

Learn why WebSockets beat REST polling for live market data, plus subscribe, heartbeat, reconnect with backoff, gap detection, and non-blocking message processing.

3 min read

Engineering content only — about software integration, not investment advice or trading signals. Connecting to live execution is your responsibility and carries real risk.

REST is request/response: you ask, the server answers, the connection closes. To watch a fast-moving market that way you'd poll constantly — wasting rate limit, adding latency, and still missing updates between polls. WebSockets invert the model. You open one persistent, full-duplex connection, subscribe once, and the server pushes updates as they happen.

Why WebSockets over polling

  • Lower latency: updates arrive when they occur, not on your next poll interval.
  • Less overhead: one handshake instead of an HTTP round trip per request.
  • Server-driven: the broker decides when there's something new — you don't guess.

The trade-off is that you now own a long-lived connection, which means handling subscriptions, liveness, reconnection, and ordering yourself.

Subscribe

After the socket opens (and authenticates, if required), send a subscription message naming the channels and symbols you want. This selects which data you receive — it is purely a plumbing concern and says nothing about trading decisions.

{ "action": "subscribe", "channels": ["quotes", "trades"], "symbols": ["ABC", "XYZ"] }

Heartbeats and liveness

TCP can appear "connected" long after the peer is gone. Most market-data feeds send periodic heartbeat or ping frames. Track the time since the last message; if it exceeds a threshold (say 1.5× the expected heartbeat interval), treat the connection as dead and reconnect. Respond to server pings promptly, and send your own pings if the protocol expects them.

Reconnect with backoff

Disconnects are normal, not exceptional. When the socket drops, reconnect — but with exponential backoff and jitter so a broker outage doesn't get hammered by every client retrying in lockstep. On reconnect you must re-subscribe; the new connection knows nothing about your old subscriptions.

Handling gaps with sequence numbers

A reconnect (or a dropped frame) means you may have missed messages. Quality feeds attach a monotonic sequence number to each message. Track the last sequence you processed; if the next one isn't last + 1, you have a gap. The standard recovery is to snapshot the authoritative state from REST (e.g. GET /v1/positions or a quote snapshot), then resume applying stream messages from there. Never silently ignore a gap — your local view will drift.

Process messages without blocking

The receive loop must stay responsive. If you do heavy work — database writes, downstream HTTP calls, order logic — inline, the socket buffer backs up, heartbeats are missed, and you get disconnected. Decouple receiving from processing with a queue.

Async WebSocket client with reconnect, gap detection, and a worker queue
import asyncio, json, random, contextlib
import websockets  # pip install websockets
 
URL = "wss://stream.broker.example/v1/marketdata"
SUBSCRIBE = {"action": "subscribe", "channels": ["quotes"], "symbols": ["ABC", "XYZ"]}
 
async def worker(queue):
    while True:
        msg = await queue.get()
        # Heavy work goes here — keep it OFF the receive loop.
        # await persist(msg) / await update_book(msg)
        queue.task_done()
 
async def consume(queue):
    backoff = 1
    last_seq = None
    while True:
        try:
            async with websockets.connect(URL, ping_interval=20, ping_timeout=10) as ws:
                await ws.send(json.dumps(SUBSCRIBE))
                backoff = 1  # reset after a successful connect
                async for raw in ws:
                    msg = json.loads(raw)
                    seq = msg.get("seq")
                    if last_seq is not None and seq is not None and seq != last_seq + 1:
                        print(f"GAP detected: {last_seq} -> {seq}; snapshot from REST and resync")
                        # await resync_from_rest()
                    last_seq = seq
                    queue.put_nowait(msg)  # hand off; don't process inline
        except Exception as e:
            sleep = min(backoff, 30) + random.random()
            print(f"disconnected ({e}); reconnecting in {sleep:.1f}s")
            await asyncio.sleep(sleep)
            backoff = min(backoff * 2, 30)
 
async def main():
    queue = asyncio.Queue(maxsize=10000)
    w = asyncio.create_task(worker(queue))
    try:
        await consume(queue)
    finally:
        w.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await w
 
if __name__ == "__main__":
    asyncio.run(main())

A few notes on the example: ping_interval/ping_timeout give you library-level liveness checks; the async for loop only parses and enqueues, keeping it fast; a bounded queue (maxsize) means that if your worker can't keep up, you fail loudly rather than ballooning memory. If put_nowait raises QueueFull, that's a real signal — your consumer is overloaded and you must shed load or scale out.

Where to go next

You now have two complementary views of broker state: the authoritative, pull-based REST API from Anatomy of a Broker REST API, and a low-latency push stream over WebSockets. Real systems use both — REST as the source of truth and for gap recovery, WebSockets for freshness. Next, explore how external signals reach your system in the first place by reading From TradingView Alert to Webhook, which closes the loop from a charting alert to a server you control.

Educational content. This article covers software development and research methods only. It is not investment advice, a trading signal, or a recommendation. See our disclaimer.