Polymarket WebSocket Freezes: What Breaks and Why Teams Use Bravado

Polymarket WebSockets can go silent without closing. What we measured over 32 hours, what a DIY fix takes, and how Bravado manages the connection layer.

If you build on Polymarket's real-time feeds, at some point your WebSocket will stay open, keep accepting your pings, and stop sending you data. Nothing raises an error. Your bot just stops seeing trades, and your dashboard shows a market that looks quiet when it isn't.

This post covers what actually breaks on Polymarket's two public WebSockets, using 32 hours of measurements on 20 markets, and what it takes to handle it yourself. It also covers how Bravado's managed WebSocket deals with the connection layer, and where you still need your own safeguards.

Which Polymarket WebSockets are there?

Polymarket has two public streams that developers use for live market data. They run on different hosts, use different message formats, and need different keepalives.


CLOB market channel

RTDS (Real-Time Data Socket)

Endpoint

wss://ws-subscriptions-clob.polymarket.com/ws/market

wss://ws-live-data.polymarket.com

Subscribe

{"assets_ids": [...], "type": "market"}

{"action": "subscribe", "subscriptions": [{"topic": "activity", "type": "trades"}]}

Scope

The outcome tokens you list

activity/trades with no filter: every trade on Polymarket

Messages

book, price_change, last_trade_price, tick_size_change

{"topic", "type", "timestamp", "payload"}

Keepalive

Text PING every 10 s, server replies PONG

Text PING every 5 s

Both are documented in Polymarket's real-time data docs. Price topics are moving from RTDS to PolyBolt (wss://ws-live-v2.polymarket.com/ws). The activity trade topic stays on RTDS, according to the migration page.

What is a Polymarket WebSocket freeze?

A freeze is a socket that is still open (no close frame, no error) but delivers no data while trades are happening. A disconnect is easy: your library sees it and reconnects. A freeze is the expensive case, because most clients wait forever for a close that never comes.

Three things make freezes hard to catch:

  1. The server stops sending but keeps the socket open. On RTDS we saw connections go silent for 74 to 100 minutes before the server eventually closed them. Three of the four times it closed with code 1001 Going away.

  2. The heartbeat doesn't prove anything. The CLOB channel answered every PING with PONG. RTDS never sent a text PONG in our test, not even on healthy connections. Either way, a heartbeat tells you the socket is up, not that your subscription is delivering data.

  3. The network path can go half-open. NAT gateways and load balancers can drop a connection's state silently. With default Linux TCP settings the socket can look open for around 15 minutes before the operating system gives up on it.

Per-minute message counts from 13:40 to 15:50 ET on Oct 11, 2026. The robust RTDS connection receives about 2,000 to 4,000 messages a minute throughout. The naive RTDS connection receives nothing for 100 minutes from 13:53 while its socket stays open, even though Data API trades keep happening on the 20 tracked markets. Below, a table lists the four naive RTDS freezes: 100, 98, 80 and 74 minutes, with 319, 142, 144 and 145 trades inside. Three ended with close code 1001 and one with an abrupt close.

What breaks? Our 32-hour measurement

Between Oct 11 and Oct 14, 2026, we measured both public Polymarket WebSockets for 32 clean hours on 20 liquid markets (40 outcome tokens). Four clients ran side by side:

Client

Feed

Behaviour

CLOB naive

Market channel

PING every 10 s, reconnect only on close or error

CLOB robust

Market channel

Same, plus reconnect after 60 s with no data

RTDS naive

activity/trades

PING every 5 s, reconnect only on close or error

RTDS robust

activity/trades

Same, plus reconnect after 60 s with no data

For ground truth, we polled the Data API /v2/trades for the same 20 markets every 10 minutes and matched trades by transaction hash. We counted only windows where all four clients and our ground-truth poller were running, 32.3 hours in total. A silence counts as a freeze only if the Data API shows trades on our markets inside it.


Freezes ≥60 s with trades inside (32.3 h)

Trades missed (steady state)

CLOB naive

0

3 of 2,842 (0.1%)

CLOB robust

0

3 of 2,842 (0.1%)

RTDS naive

4, lasting 74–100 min each

1,840 of 2,842 (64.7%)

RTDS robust

48 caught by the 60 s watchdog

69 of 2,842 (2.4%)

"Steady state" means measurement windows of at least 40 minutes, with the first 20 minutes of each skipped. That leaves 20.6 hours and 2,842 trades on our markets.

Results table for four Polymarket WebSocket clients over 32.3 measured hours on 20 markets. The CLOB naive and CLOB robust clients had no freezes of 60 seconds or more with trades inside, and each missed 3 of 2,842 trades (0.1%). The RTDS naive client had 4 freezes of 74 to 100 minutes and missed 1,840 trades (64.7%). The RTDS robust client had 48 freezes, each caught after 60 seconds, and missed 69 trades (2.4%). Tiles: 32.3 hours measured, 750 trades inside the naive RTDS freezes, 0 trades missed by both robust clients together.

1. The CLOB market channel held up

Neither CLOB client had a single silence of 60 seconds or more while trades were happening, and both received all but 3 of 2,842 trades. That is 32 hours of evidence, not a guarantee.

2. RTDS connections go silent without closing

Without a watchdog, the RTDS client sat on a dead subscription four times, for 74 to 100 minutes each, while 750 trades happened on our markets. The other RTDS connection was receiving data at the same moments, so these were stalls on individual connections, not an outage.

Line chart of cumulative trades received over 20.6 steady-state hours. The Data API ground truth reaches 2,842 trades. The CLOB naive, CLOB robust and RTDS robust lines track it almost exactly, ending at 2,839, 2,839 and 2,773. The RTDS naive line flattens repeatedly during silent periods and ends at 1,002.

3. A watchdog helps, but it still leaks

With a 60-second no-data watchdog, the RTDS client reconnected 66 times in 32.3 hours, and 48 of those silences overlapped real trades on our markets. Connections ran for a median of about 13 minutes before going quiet. After every reconnect, data was flowing again within 5 seconds (median 2.5 s). The client still missed 2.4% of trades: the ones inside each detection window.

Histogram of how many minutes robust RTDS connections delivered data before going silent, for 42 connections, with a median of 12.8 minutes. Tiles: 66 watchdog reconnects in 32.3 hours, 48 of which overlapped real trades. All 66 had data back within 5 seconds, with a median of 2.47 seconds. 2.4% of trades were still missed inside the 60-second detection windows.

4. Limits of the test

The 32 measured hours are not one continuous run, and many came in short windows, so we don't quote per-day rates as precise. We tested 20 liquid, long-dated markets and the unfiltered RTDS trade topic. We did not test filtered subscriptions, PolyBolt, or orders_matched.

What does it take to handle freezes yourself?

Handling this properly takes more than a reconnect loop. A production client needs every piece in the table below.

Piece

Why it matters

Resubscribe on every connection

A subscription does not survive a reconnect

Documented heartbeat

Text PING every 10 s (CLOB) or 5 s (RTDS) keeps the socket alive, but proves nothing about data

Data watchdog

The only reliable signal of a frozen subscription is missing data

Reconnect with backoff

Recover without hammering the server

REST backfill

Fetch trades from /v2/trades for the silent window, re-sending your filters on every page

De-duplication

Merge live and backfilled trades by transaction hash

Monitoring

Know when the feed is stalling more than usual

The core piece is the watchdog. Here is a compact Python version for the RTDS trade stream (pip install websockets):

import asyncio, json, time
import websockets

RTDS_URL = "wss://ws-live-data.polymarket.com"
SUBSCRIBE = json.dumps({"action": "subscribe",
                        "subscriptions": [{"topic": "activity", "type": "trades"}]})
SILENCE_S = 30          # the unfiltered trade stream is busy; 30 s of nothing is a stall

async def heartbeat(ws):
    while True:
        await asyncio.sleep(5)
        await ws.send("PING")                    # keeps the socket alive, proves nothing

async def run(on_message):
    backoff = 1
    while True:
        gap_start = time.time()
        try:
            async with websockets.connect(RTDS_URL, ping_interval=None) as ws:
                await ws.send(SUBSCRIBE)         # resubscribe on every connection
                hb = asyncio.create_task(heartbeat(ws))
                last = time.monotonic()
                try:
                    while True:
                        try:
                            raw = await asyncio.wait_for(ws.recv(), timeout=1)
                        except asyncio.TimeoutError:
                            if time.monotonic() - last > SILENCE_S:
                                raise TimeoutError("silent connection")
                            continue
                        if raw in ("PONG", ""):
                            continue
                        last, gap_start, backoff = time.monotonic(), time.time(), 1
                        on_message(json.loads(raw))
                finally:
                    hb.cancel()
        except Exception as e:
            print(f"reconnecting ({e}); backfill trades since {gap_start:.0f}")
        await asyncio.sleep(backoff)
        backoff = min(backoff * 2, 60)

asyncio.run(run(lambda m: None))
import asyncio, json, time
import websockets

RTDS_URL = "wss://ws-live-data.polymarket.com"
SUBSCRIBE = json.dumps({"action": "subscribe",
                        "subscriptions": [{"topic": "activity", "type": "trades"}]})
SILENCE_S = 30          # the unfiltered trade stream is busy; 30 s of nothing is a stall

async def heartbeat(ws):
    while True:
        await asyncio.sleep(5)
        await ws.send("PING")                    # keeps the socket alive, proves nothing

async def run(on_message):
    backoff = 1
    while True:
        gap_start = time.time()
        try:
            async with websockets.connect(RTDS_URL, ping_interval=None) as ws:
                await ws.send(SUBSCRIBE)         # resubscribe on every connection
                hb = asyncio.create_task(heartbeat(ws))
                last = time.monotonic()
                try:
                    while True:
                        try:
                            raw = await asyncio.wait_for(ws.recv(), timeout=1)
                        except asyncio.TimeoutError:
                            if time.monotonic() - last > SILENCE_S:
                                raise TimeoutError("silent connection")
                            continue
                        if raw in ("PONG", ""):
                            continue
                        last, gap_start, backoff = time.monotonic(), time.time(), 1
                        on_message(json.loads(raw))
                finally:
                    hb.cancel()
        except Exception as e:
            print(f"reconnecting ({e}); backfill trades since {gap_start:.0f}")
        await asyncio.sleep(backoff)
        backoff = min(backoff * 2, 60)

asyncio.run(run(lambda m: None))
import asyncio, json, time
import websockets

RTDS_URL = "wss://ws-live-data.polymarket.com"
SUBSCRIBE = json.dumps({"action": "subscribe",
                        "subscriptions": [{"topic": "activity", "type": "trades"}]})
SILENCE_S = 30          # the unfiltered trade stream is busy; 30 s of nothing is a stall

async def heartbeat(ws):
    while True:
        await asyncio.sleep(5)
        await ws.send("PING")                    # keeps the socket alive, proves nothing

async def run(on_message):
    backoff = 1
    while True:
        gap_start = time.time()
        try:
            async with websockets.connect(RTDS_URL, ping_interval=None) as ws:
                await ws.send(SUBSCRIBE)         # resubscribe on every connection
                hb = asyncio.create_task(heartbeat(ws))
                last = time.monotonic()
                try:
                    while True:
                        try:
                            raw = await asyncio.wait_for(ws.recv(), timeout=1)
                        except asyncio.TimeoutError:
                            if time.monotonic() - last > SILENCE_S:
                                raise TimeoutError("silent connection")
                            continue
                        if raw in ("PONG", ""):
                            continue
                        last, gap_start, backoff = time.monotonic(), time.time(), 1
                        on_message(json.loads(raw))
                finally:
                    hb.cancel()
        except Exception as e:
            print(f"reconnecting ({e}); backfill trades since {gap_start:.0f}")
        await asyncio.sleep(backoff)
        backoff = min(backoff * 2, 60)

asyncio.run(run(lambda m: None))
import asyncio, json, time
import websockets

RTDS_URL = "wss://ws-live-data.polymarket.com"
SUBSCRIBE = json.dumps({"action": "subscribe",
                        "subscriptions": [{"topic": "activity", "type": "trades"}]})
SILENCE_S = 30          # the unfiltered trade stream is busy; 30 s of nothing is a stall

async def heartbeat(ws):
    while True:
        await asyncio.sleep(5)
        await ws.send("PING")                    # keeps the socket alive, proves nothing

async def run(on_message):
    backoff = 1
    while True:
        gap_start = time.time()
        try:
            async with websockets.connect(RTDS_URL, ping_interval=None) as ws:
                await ws.send(SUBSCRIBE)         # resubscribe on every connection
                hb = asyncio.create_task(heartbeat(ws))
                last = time.monotonic()
                try:
                    while True:
                        try:
                            raw = await asyncio.wait_for(ws.recv(), timeout=1)
                        except asyncio.TimeoutError:
                            if time.monotonic() - last > SILENCE_S:
                                raise TimeoutError("silent connection")
                            continue
                        if raw in ("PONG", ""):
                            continue
                        last, gap_start, backoff = time.monotonic(), time.time(), 1
                        on_message(json.loads(raw))
                finally:
                    hb.cancel()
        except Exception as e:
            print(f"reconnecting ({e}); backfill trades since {gap_start:.0f}")
        await asyncio.sleep(backoff)
        backoff = min(backoff * 2, 60)

asyncio.run(run(lambda m: None))

Two notes. First, the watchdog runs on time.monotonic(), so clock adjustments can't trip it. Second, gap_start is the time of the last good message, which is where your REST backfill should start. When you page /v2/trades for that backfill, send condition= again with every cursor. The Data API docs say the cursor carries only its position in the feed, so a bare cursor returns the global trade feed.

Six-step diagram of handling WebSocket freezes yourself: 1, connect and subscribe; 2, heartbeat; 3, data watchdog; 4, reconnect with backoff; 5, backfill from the Data API's /v2/trades; 6, de-duplicate by transaction hash. A note says the RTDS client still missed 2.4% of trades with steps 1 to 4 alone. Two panels below: Bravado manages steps 1 to 4 on the upstream RTDS connection, with a 30-second silence watchdog, plus de-duplication of repeated frames. Step 5, backfill through REST endpoints, is still yours, because trades are not replayed into the stream automatically.

None of this is exotic, but it is code you have to write, test and keep running. The work adds up when you need every trade rather than most of them, when you track the whole market (the RTDS stream runs to tens of trades per second at busy times), or when your workers get recycled by autoscaling or spot instances.

How does Bravado handle the connection layer?

Bravado's Data API and WebSockets take the connection layer off your hands, and leave the gap-filling to you on your own terms.

What you'd build yourself

What Bravado provides today

RTDS connection, heartbeat, resubscribe

Managed upstream connection, resubscribed on every reconnect

Data watchdog

30-second silence watchdog with automatic reconnect

De-duplication around reconnects

Repeated frames de-duplicated before they reach you

Something to show on connect

The last ~200 trades, sent when you connect

Order-book connections

Order books relayed from Polymarket's CLOB market channel

REST backfill

REST endpoints you call yourself to check or rebuild a window

  • A managed RTDS connection. The Live Trades WebSocket relays Polymarket's RTDS trade stream, with every fill enriched with the trader's name and wallet. On our side, the upstream connection runs a 30-second silence watchdog and reconnects and resubscribes automatically. Repeated frames around a reconnect are de-duplicated before they reach you.

  • A short buffer on connect. When you connect, you get the last ~200 trades first, so your screen isn't empty. At busy times that covers only a few seconds. It is not a replacement for a backfill.

  • Order books. Live order books are relayed from Polymarket's CLOB market channel. See the Market Data overview for the full set.

  • REST endpoints for your own backfill. The historical data and Trader Data API endpoints cover wallet trades, price history and volume history. Call them to check or rebuild a window after an interruption.

  • One API across venues. The same keys and conventions cover Polymarket and Predict.fun.

  • Simple pricing to start. The evaluation plan includes 5,000 API credits a month, and Data API requests cost 1 credit each. The API credits page lists costs per product.

What Bravado does not do is make freezes impossible or fill gaps automatically. If the upstream goes quiet, the watchdog reconnects within about 30 seconds, and trades inside that window are not replayed into the stream. Keep a light watchdog and a backfill path on your side. The difference is that you no longer own the upstream connection itself.

Example: a bot watching Bitcoin up/down markets

Polymarket runs rolling Bitcoin "Up or Down" markets on 5-minute, 15-minute and hourly windows. Each one resolves on Chainlink's BTC price over its window, and the event slugs look like btc-updown-15m-<start epoch>. A bot that watches the 15-minute markets lives on trade flow. If its connection goes silent, three things go wrong at once:

  • Stale signal. The last price it saw might be minutes old, and in a 15-minute market that can be most of the window.

  • Missed fills. Trades it never received never enter its volume or flow numbers.

  • Wrong position view. If it tracks its own wallet's fills from the stream, it can think it is flat when it isn't.

With Bravado's Live Trades WebSocket, the upstream connection, the 30-second watchdog, reconnects and de-duplication are handled for you. The route has no subscription filter, so the bot filters on eventSlug itself:

import asyncio, hashlib, hmac, json, os, time
import websockets

PATH = "/stream/polymarket/partner/trades"

def headers():
    ts = str(int(time.time() * 1000))
    payload = "\n".join([ts, "GET", PATH, hashlib.sha256(b"").hexdigest()])
    sig = hmac.new(os.environ["BRAVADO_API_SECRET"].encode(), payload.encode(), "sha256").hexdigest()
    return {"X-BRAVADO-API-KEY": os.environ["BRAVADO_API_KEY"],
            "X-BRAVADO-TIMESTAMP": ts, "X-BRAVADO-SIGNATURE": sig}

async def main():
    seen, last_ts = set(), None
    while True:
        try:   # fresh signature on every (re)connect
            async with websockets.connect("wss://partner-api.bravadotrade.com" + PATH,
                                          additional_headers=headers()) as ws:
                async for raw in ws:
                    msg = json.loads(raw)
                    trades = msg["trades"] if msg["type"] == "snapshot" else [msg["trade"]]
                    if msg["type"] == "snapshot" and last_ts and trades and min(t["timestamp"] for t in trades) > last_ts:
                        print("gap since", last_ts, "- backfill from REST before trusting state")
                    for t in trades:
                        if not (t.get("eventSlug") or "").startswith("btc-updown-15m-"):
                            continue
                        if t["transactionHash"] in seen:   # dedupe on the hash, not id
                            continue
                        seen.add(t["transactionHash"])
                        last_ts = max(last_ts or 0, t["timestamp"])
                        print(t["outcome"], t["side"], t["price"], t["size"])
        except Exception as e:
            print("reconnecting:", e)
            await asyncio.sleep(2)

asyncio.run(main())
import asyncio, hashlib, hmac, json, os, time
import websockets

PATH = "/stream/polymarket/partner/trades"

def headers():
    ts = str(int(time.time() * 1000))
    payload = "\n".join([ts, "GET", PATH, hashlib.sha256(b"").hexdigest()])
    sig = hmac.new(os.environ["BRAVADO_API_SECRET"].encode(), payload.encode(), "sha256").hexdigest()
    return {"X-BRAVADO-API-KEY": os.environ["BRAVADO_API_KEY"],
            "X-BRAVADO-TIMESTAMP": ts, "X-BRAVADO-SIGNATURE": sig}

async def main():
    seen, last_ts = set(), None
    while True:
        try:   # fresh signature on every (re)connect
            async with websockets.connect("wss://partner-api.bravadotrade.com" + PATH,
                                          additional_headers=headers()) as ws:
                async for raw in ws:
                    msg = json.loads(raw)
                    trades = msg["trades"] if msg["type"] == "snapshot" else [msg["trade"]]
                    if msg["type"] == "snapshot" and last_ts and trades and min(t["timestamp"] for t in trades) > last_ts:
                        print("gap since", last_ts, "- backfill from REST before trusting state")
                    for t in trades:
                        if not (t.get("eventSlug") or "").startswith("btc-updown-15m-"):
                            continue
                        if t["transactionHash"] in seen:   # dedupe on the hash, not id
                            continue
                        seen.add(t["transactionHash"])
                        last_ts = max(last_ts or 0, t["timestamp"])
                        print(t["outcome"], t["side"], t["price"], t["size"])
        except Exception as e:
            print("reconnecting:", e)
            await asyncio.sleep(2)

asyncio.run(main())
import asyncio, hashlib, hmac, json, os, time
import websockets

PATH = "/stream/polymarket/partner/trades"

def headers():
    ts = str(int(time.time() * 1000))
    payload = "\n".join([ts, "GET", PATH, hashlib.sha256(b"").hexdigest()])
    sig = hmac.new(os.environ["BRAVADO_API_SECRET"].encode(), payload.encode(), "sha256").hexdigest()
    return {"X-BRAVADO-API-KEY": os.environ["BRAVADO_API_KEY"],
            "X-BRAVADO-TIMESTAMP": ts, "X-BRAVADO-SIGNATURE": sig}

async def main():
    seen, last_ts = set(), None
    while True:
        try:   # fresh signature on every (re)connect
            async with websockets.connect("wss://partner-api.bravadotrade.com" + PATH,
                                          additional_headers=headers()) as ws:
                async for raw in ws:
                    msg = json.loads(raw)
                    trades = msg["trades"] if msg["type"] == "snapshot" else [msg["trade"]]
                    if msg["type"] == "snapshot" and last_ts and trades and min(t["timestamp"] for t in trades) > last_ts:
                        print("gap since", last_ts, "- backfill from REST before trusting state")
                    for t in trades:
                        if not (t.get("eventSlug") or "").startswith("btc-updown-15m-"):
                            continue
                        if t["transactionHash"] in seen:   # dedupe on the hash, not id
                            continue
                        seen.add(t["transactionHash"])
                        last_ts = max(last_ts or 0, t["timestamp"])
                        print(t["outcome"], t["side"], t["price"], t["size"])
        except Exception as e:
            print("reconnecting:", e)
            await asyncio.sleep(2)

asyncio.run(main())
import asyncio, hashlib, hmac, json, os, time
import websockets

PATH = "/stream/polymarket/partner/trades"

def headers():
    ts = str(int(time.time() * 1000))
    payload = "\n".join([ts, "GET", PATH, hashlib.sha256(b"").hexdigest()])
    sig = hmac.new(os.environ["BRAVADO_API_SECRET"].encode(), payload.encode(), "sha256").hexdigest()
    return {"X-BRAVADO-API-KEY": os.environ["BRAVADO_API_KEY"],
            "X-BRAVADO-TIMESTAMP": ts, "X-BRAVADO-SIGNATURE": sig}

async def main():
    seen, last_ts = set(), None
    while True:
        try:   # fresh signature on every (re)connect
            async with websockets.connect("wss://partner-api.bravadotrade.com" + PATH,
                                          additional_headers=headers()) as ws:
                async for raw in ws:
                    msg = json.loads(raw)
                    trades = msg["trades"] if msg["type"] == "snapshot" else [msg["trade"]]
                    if msg["type"] == "snapshot" and last_ts and trades and min(t["timestamp"] for t in trades) > last_ts:
                        print("gap since", last_ts, "- backfill from REST before trusting state")
                    for t in trades:
                        if not (t.get("eventSlug") or "").startswith("btc-updown-15m-"):
                            continue
                        if t["transactionHash"] in seen:   # dedupe on the hash, not id
                            continue
                        seen.add(t["transactionHash"])
                        last_ts = max(last_ts or 0, t["timestamp"])
                        print(t["outcome"], t["side"], t["price"], t["size"])
        except Exception as e:
            print("reconnecting:", e)
            await asyncio.sleep(2)

asyncio.run(main())

The bot still checks for gaps itself. The snapshot on connect holds only the last 200 trades across all of Polymarket, which is a few seconds at busy times, and missed trades are not replayed. If the snapshot doesn't reach back to the bot's last trade, it backfills from REST before acting. If the bot also needs depth, it can pass each assetId to the authenticated order-book feed.

Conclusion

A Polymarket WebSocket that is open is not necessarily one that is working. In our 32-hour test, the CLOB market channel held up well. RTDS trade connections regularly went silent without closing, and one stayed silent for 100 minutes while hundreds of trades went by. Handling that yourself means a watchdog, reconnects, resubscribes, REST backfill and de-duplication, all running and monitored.

If you'd rather not run that layer, Bravado's Data API and WebSockets give you a managed connection with a silence watchdog, de-duplicated trades, order books, and REST endpoints for your own backfill. The quickstart takes a few minutes, and the evaluation plan includes 5,000 API credits a month. For more on Polymarket's APIs, see our guides to the Polymarket Data API v2 and calculating Polymarket PnL.

Read more blogs

Trading insights, market analysis, and strategies for Polymarket prediction markets.

© 2026 Bravado. All rights reserved.

Not financial advice. Trade responsibly.

© 2026 Bravado. All rights reserved.

Not financial advice. Trade responsibly.

© 2026 Bravado. All rights reserved.

Not financial advice. Trade responsibly.

© 2026 Bravado. All rights reserved.

Not financial advice. Trade responsibly.