DEV Community

Cover image for Reconnect a Live Odds Feed Without Losing Your Local State
E. Mitev
E. Mitev

Posted on

Reconnect a Live Odds Feed Without Losing Your Local State

A WebSocket reconnects. Our application starts receiving prices again. Is its local state correct?

Not necessarily.

The connection may have missed removals, replayed changes or received only part of a replacement snapshot. A green connection indicator tells us that messages can arrive—not that our local board is ready to use.

In this tutorial, we examine recovery after a connection loss using PulseScore’s Python reference client. We inspect its reconnect declaration and test replacement snapshots locally, without opening a network connection.

Disclosure: We build PulseScore and have a commercial interest in it. We reviewed the reference client and public documentation on October 25, 2026.

Three pieces of state

Reliable recovery starts with separating three things:

State Question it answers
Connection Can messages arrive?
Board What data does our application currently hold?
Applied cursor Through which update has that board been updated?

The cursor must describe changes that have actually been applied.

Suppose a message enters a queue and we immediately advance the cursor. If the process stops before applying that message, our next reconnect may request updates after a change that never reached the board.

The connection can recover while the application silently retains an inconsistent view.

Keep the board and its applied cursor together.

If a process restart preserves only the cursor, replay cannot reconstruct the missing starting state. Persist both consistently, or start again with a fresh snapshot.

Understand the recovery contract

PulseScore’s Stream API accepts a per-stream since cursor when reconnecting.

Eligible streams can resume within the documented 120-second replay window. When replay is unavailable, the server sends a replacement snapshot. Filtered pre-match streams, streams reporting seq: 0 and cursors older than the replay window also start with snapshots.

A cursor therefore supports recovery; it does not guarantee that every reconnect will resume.

Our application needs to handle both outcomes:

  • Replay: retain the matching local board and apply the missed changes.
  • Replacement snapshot: reset the affected board and rebuild it from the incoming snapshot.

These rules apply to the Stream API’s snapshot-and-delta protocol. The separate live WebSocket product sends full in-play boards and has a different integration model.

Check the reference client locally

We can test how the Python reference client builds a reconnect declaration using synthetic protocol frames.

Download apply.py, review it and place it in a project folder.

Save the following script as check_resume.py in the same folder.

The script uses local client methods. It does not call run(), open a socket or require a real API key.

from apply import StreamConnection


stream = "bet365/live/soccer"

connection = StreamConnection(
    url="wss://api.pulsescore.net/api/stream/ws",
    key="offline-test-key",
    streams=[
        {
            "bookmaker": "bet365",
            "kind": "live",
            "sport": "soccer",
        }
    ],
)

# Simulate the server confirming our subscription.
connection.client.apply({
    "t": "connected",
    "conn": "synthetic-connection",
    "protocol": 2,
    "streams": [
        {
            "stream": stream,
            "bookmaker": "bet365",
            "kind": "live",
            "sport": "soccer",
        }
    ],
})

# run() normally performs this bookkeeping after "connected".
connection.on_connected()

# Build a starting board from a synthetic snapshot.
connection.client.apply({
    "t": "snapshot",
    "stream": stream,
    "seq": 100,
    "part": 1,
    "of": 1,
    "events": [
        {
            "eventId": "demo-1",
            "home": "A",
            "away": "B",
            "markets": [],
        }
    ],
})

connection.client.apply({
    "t": "snapshot_end",
    "stream": stream,
    "seq": 100,
    "count": 1,
})

# Apply one change to that board.
connection.client.apply({
    "t": "delta",
    "stream": stream,
    "seq": 101,
    "changes": [
        {
            "eventId": "demo-1",
            "op": "patch",
            "set": {
                "score": {
                    "home": "1",
                    "away": "0",
                }
            },
        }
    ],
})

# The reconnect declaration should use the applied sequence.
declaration = connection.declaration()

assert declaration["streams"][0]["since"] == 101

board = connection.client.board(stream).to_list()
assert board[0]["score"]["home"] == "1"

print(declaration)

# A replacement snapshot resets this stream's old board.
connection.client.apply({
    "t": "snapshot",
    "stream": stream,
    "seq": 200,
    "part": 1,
    "of": 1,
    "events": [],
})

connection.client.apply({
    "t": "snapshot_end",
    "stream": stream,
    "seq": 200,
    "count": 0,
})

assert connection.client.board(stream).to_list() == []

print("Cursor and replacement-snapshot checks passed.")
Enter fullscreen mode Exit fullscreen mode

Run it:

python check_resume.py
Enter fullscreen mode Exit fullscreen mode

Expected output:

{'streams': [{'bookmaker': 'bet365', 'kind': 'live', 'sport': 'soccer', 'since': 101}]}
Cursor and replacement-snapshot checks passed.
Enter fullscreen mode Exit fullscreen mode

The key, event and sequence numbers are synthetic.

We ran this local check against the reference client reviewed for this article. It verifies the applied cursor and replacement-board behavior. It does not verify server availability, authentication or a live reconnect.

For a reproducible project, retain the reviewed copy of apply.py with the example rather than assuming future versions expose identical local methods.

What this check demonstrates

The script tests two important behaviors.

First, after applying the delta at sequence 101, the reconnect declaration uses since: 101. The board also contains the score introduced by that update.

Second, an empty replacement snapshot removes the previous event.

That second case is easy to overlook. An empty board is a valid result. Keeping old rows because the replacement contains no events would incorrectly suggest that those events are still present.

The test operates directly on the client’s local state. In a running connection, the connection keeper processes incoming frames and performs the associated connection bookkeeping.

Make recovery visible in our application

During a brief interruption, we may choose to keep the previous board visible. If we do, we should label it as potentially stale.

When a replacement snapshot begins, the affected stream enters a rebuilding phase. We should not present a partially received board as complete.

A practical application can distinguish these states:

Application state Meaning
Connecting No usable board is available yet
Ready The stream has completed its initial or recovery workflow
Reconnecting Cached data may be stale while the connection recovers
Rebuilding A replacement snapshot is arriving
Stopped Automatic recovery has stopped; inspection or action may be needed

These are application-level labels. They are not a hosted dashboard supplied by PulseScore, and they do not necessarily map one-to-one to the reference client’s connection states.

For multiple subscriptions, track readiness per stream. One completed board does not mean every board on the connection is ready.

Snapshot completion is a necessary boundary for rebuilding. If recovery includes catch-up updates, our application should also process those updates in order before treating the recovered view as current.

Retry only recoverable failures

A temporary network interruption and an invalid API key need different responses.

Blind retries can hide the actual problem and repeatedly compete for connection slots.

For recoverable failures:

  • Use exponential backoff with a maximum delay and jitter.
  • Respect server retry hints.
  • Keep the local board and applied cursor available for eligible recovery.
  • Record whether recovery used replay or a replacement snapshot.

For configuration or account failures, surface the reason so it can be corrected.

Use the current Stream API reconnect and close-code rules. Do not assume that every WebSocket product uses the same retry policy.

Also distinguish a disconnected consumer from a slow consumer.

If expensive processing prevents the receive loop from keeping up, reconnecting may reproduce the same failure. Move heavy work away from the reader, monitor queue growth or reduce the subscription.

Test recovery separately from the happy path

The synthetic check above covers:

  • Applying a patch.
  • Building a reconnect declaration from the applied cursor.
  • Replacing an existing board with an empty snapshot.

A broader recovery test suite should also cover:

  1. Event removal.
  2. Replayed updates.
  3. Snapshots split across multiple pages.
  4. A disconnect before snapshot completion.
  5. Several streams recovering independently.
  6. A process restart without a persisted board.
  7. Shutdown while recovery is in progress.

For authenticated testing, use a narrow subscription and perform a controlled interruption. Observe the protocol frames and confirm whether the server resumes the stream or sends a new snapshot.

A local test and a live recovery test answer different questions. Both are useful, but neither should be described as proving the other.

A connected socket is only the beginning

Successful recovery means that our board, cursor and application status agree.

We need to know which updates were applied, whether a snapshot replaced earlier data and whether the view is still rebuilding or potentially stale.

The result should be a local board we can explain after a failure—not just a socket that eventually reconnects.

How does your application distinguish a connected feed from a trustworthy local view?

References

Top comments (0)