How-to

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.

On this page