We run a live arbitrage scanner that prices esports markets on Polymarket and Kalshi against 25 sportsbooks. Both exchanges are read through WebSocket order-book channels in Go (gorilla/websocket). A wrong top-of-book here is not a cosmetic bug: the price becomes decimal odds, the odds become an arb, and the arb is someone's real money. These are the lessons that cost us the most to learn.
1. On Polymarket, trust the server's best bid/ask, not the book you rebuilt
Polymarket's public /ws/market channel sends a book snapshot and then price_change deltas that also carry the server's best_bid/best_ask. The obvious design is to apply deltas to a local book and read the top of it. We measured that on a live channel — 1,200 tokens, 5 minutes, 24.6k deltas:
- About 1% of deltas left our locally rebuilt book with a different top-of-book than the server reported, and the divergence was sticky — it did not converge by itself.
- We checked who was right by forcing a fresh snapshot: of 106 divergences, the new
bookconfirmed the server's best in 87 cases and our local book in one.
So the price comes from the server's best_bid/best_ask, and the divergence itself became a desync detector: if levels disagree with the server, depth from that book is not trusted and the book is rebuilt.
2. Resync by unsubscribe + subscribe — a plain re-subscribe does nothing
To rebuild one token's book we unsubscribe and subscribe it again; the server sends a fresh book in 89 ms on average (119 ms max). Re-subscribing without unsubscribing first does not work: the channel answers with the text NO NEW ASSETS and sends no snapshot.
Resyncs are batched (every 500 ms, at most 200 tokens per tick) with a per-token cooldown, so a burst of divergences cannot turn into a storm of subscription frames. One more limit we hit: a subscribe/unsubscribe message carries at most 500 asset IDs; anything past that is dropped without an error.
3. Know who answers your heartbeat
On Polymarket, protocol-level ping frames are answered — but by the Cloudflare edge, not by the origin. A dead stream behind a live edge keeps answering control pings, so a read deadline extended by those pongs would keep a frozen book "alive". We use the channel's application-level heartbeat instead: a text PING every 10 s, a text PONG back from the channel itself, and a 30 s read deadline. Writes get their own 5 s deadline — without it, a full send buffer hangs the book forever and silently.
4. On Kalshi, server pings must extend your read deadline
Kalshi does the opposite: the server sends control pings every 10 seconds, and those pings are the sign of a live channel. Our read loop extended the 30 s deadline only on data frames. gorilla/websocket's default ping handler replies with a pong but does not touch the read deadline.
Most of the week that did not matter — order books change often enough. Then, every Thursday during Kalshi's maintenance window (07:00–09:00 UTC, when trading pauses), books stop changing, and the connection dropped every 30–80 seconds with i/o timeout. Each reconnect cleared the cache and re-fetched about a thousand snapshots. It was not a regression; it had happened every Thursday since we moved Kalshi to WebSockets. The fix is four lines:
pong := conn.PingHandler()
conn.SetPingHandler(func(appData string) error {
_ = conn.SetReadDeadline(time.Now().Add(readWait))
return pong(appData)
})
A downstream guard that holds the last good snapshot when a book suddenly empties kept the drops off users' screens — which is also why it took weeks to notice.
5. Kalshi's seq is per subscription, not per market
We subscribed orderbook_delta to 30 tickers at once. The 30 orderbook_snapshot messages arrived with seq 1 to 30, and every delta after that, across all tickers, continued one shared sequence (31, 32, 33…). A gap in seq therefore means the whole subscription is suspect: we rebuild the subscription, not one market. Partial recovery would be a guess, not documented behaviour.
The WebSocket handshake is signed: an RSA-PSS signature of timestamp_ms + "GET" + "/trade-api/ws/v2" in the KALSHI-ACCESS-* headers. An unsigned request gets 401.
6. Use WebSockets only where they pay off
Measured traffic on Kalshi: 30 tickers produced 158 deltas in 25 s — 6.3 messages/s, about 0.2 per ticker. For the ~640 open esports markets that day, that extrapolates to roughly 135 messages/s in one subscription on one connection, far from needing sharding.
Market structure is a different story. market_lifecycle_v2 cannot be filtered by ticker, so it streams the lifecycle of the whole exchange — weather, politics, economics — and its messages carry no tag that says "esports". We kept structure on REST (/series?category=Sports, then /events per series, refreshed every 15 s) and moved only the order books to WebSockets, which is where the REST budget was going.
7. Decimals, stale books and one reconnect loop
-
Decimals, not floats. Odds are computed as
1/best, so a rounding error becomes a phantom arb. Prices are compared as decimals, not strings: the server does not guarantee the same text for one price in a snapshot and in a later delta. - A stale book is worse than none. On disconnect we drop the books instead of keeping them. For Polymarket the scanner then falls back to the slower Gamma listing price until fresh snapshots arrive; a frozen book would quote prices that no longer exist.
- One reconnect loop for everyone. Polymarket, Kalshi and a third WebSocket book each had their own copy of the lifecycle and backoff code (1 s, doubling to 30 s). A fix in one copy did not reach the others, so they now share one package.
What this feeds
These caches feed a canonical market model shared with the sportsbook adapters; a matcher maps the same match across 27 books, and a server-side engine recomputes arbs on every cycle, net of each market's exchange fee. The data side of the project is in our previous post, Polymarket & Kalshi Arbitrage vs Sportsbooks: 30 Days of Esports Data. The scanner itself: prediction market arbitrage; the price conversion it relies on: odds converter.
Dozor is an esports arbitrage scanner covering 25 sportsbooks plus Polymarket and Kalshi. It is an information tool, not a bookmaker. 18+.
Top comments (0)