Recover from disconnects
Sequence numbers, gap detection, and why there is no replay buffer.
Every server→client frame carries seq, a per-(connection, match) counter. A snapshot is seq
0; each following update increments.
Detect a gap
const lastSeq = new Map()
function onFrame(frame) {
if (frame.type === 'snapshot') {
lastSeq.set(frame.match_id, 0)
replaceState(frame.match_id, frame.data)
return
}
if (frame.type !== 'update') return
const expected = (lastSeq.get(frame.match_id) ?? 0) + 1
if (frame.seq !== expected) {
// We missed something. Re-subscribing re-baselines with a fresh snapshot.
ws.send(JSON.stringify({ type: 'subscribe', data: { match_ids: [frame.match_id] } }))
return
}
lastSeq.set(frame.match_id, frame.seq)
applyUpdate(frame.match_id, frame.data)
}The recovery for a gap is always the same: re-subscribe. Every subscribe begins with a fresh
snapshot, so your state is rebuilt from scratch rather than patched.
There is no replay buffer
You cannot resume from your last seq, and you don't need to. The snapshot makes a reconnect
correct, not merely tolerable — which is a deliberate trade: a replay buffer would add server
state, a retention window and a new class of "how far back can I go" bugs, to solve a problem a
snapshot already solves.
Finding out what you missed
The snapshot tells you where a match stands now. If you also need to know what changed while you were disconnected — and what it changed from — that is what match history is for:
curl -H "X-API-Key: $EO_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches/$MATCH_ID/history?limit=20"Each entry carries changed_at, the fields that moved and their from/to values, so you can
reconcile a stored copy rather than just overwrite it. Entries with was_completed: true are the
ones worth attention — those are results we published and then revised.
For a gap that spans many matches, ?changed_since= on the match list is the cheaper first step:
it tells you which matches moved, and you only fetch history for the ones you care about.
Reconnect with backoff
let attempt = 0
async function connect() {
const { data } = await mintTicket() // POST /v1/cs2/ws-token — costs no request quota
const ws = new WebSocket(`wss://api.esportsodds.gg/v1/ws?token=${data.token}`)
ws.addEventListener('open', () => {
attempt = 0
ws.send(JSON.stringify({ type: 'subscribe', data: { match_ids: [...watched] } }))
})
ws.addEventListener('close', () => {
const wait = Math.min(30_000, 2 ** attempt * 500) + Math.random() * 500
attempt += 1
setTimeout(connect, wait)
})
}Cap the backoff (30s here) and add jitter, so a fleet of clients that dropped together doesn't reconnect in lockstep.
Back off harder on connection_limit
If the close was preceded by an error frame with code: "connection_limit", you are at your
five-connection cap. Reconnecting immediately will not get you in, and a tight mint loop still
draws on the per-second rate limit even though it costs no quota. Wait, and check whether an old
connection is failing to close.
Unsolicited snapshots are normal
When our internal feed reconnects, every live subscription gets a fresh snapshot you didn't ask
for. It is not an error and not a duplicate to suppress — handle snapshot as "replace this match's
state" and it costs you nothing.
Ticket reuse
A ticket is valid for 60 seconds and is not single-use. A reconnect inside that window can reuse the one you have; only mint again once it's expired. Minting costs no request quota — neither the mint nor the connection nor anything pushed over it draws on your request allowance — but a tight reconnect loop still spends per-second rate limit, so reuse the ticket anyway.