Short answer: use a publish-oriented realtime API for roster changes, keep presence as a separate signal, and make reconnect, token expiry, and duplicate delivery explicit in the sports score feed.
The hard part is not opening a socket. It is deciding what the socket is allowed to mean. A score feed has a moving list of participants, score events that must be ordered, and clients that disappear during a tunnel ride or a phone handoff. Treating all three as one stream makes recovery ambiguous.
I build these workflows with an eval harness nearby, even when the first prototype lives in a notebook. The harness replays late packets, duplicate packets, and expired credentials before a release gets a green light. That habit changes the API decision: the boundary has to be observable and replayable, not merely convenient.
What should a participant roster sync API guarantee for a sports score feed?
Start by splitting responsibilities. The server owns authorization, the canonical roster, and business events such as participant_added or participant_removed. The client owns rendering and a local cursor. Presence answers “who is connected now?”; it is not proof that a participant belongs in the game roster.
For a small feed, the data flow can stay plain: an authorized server receives a roster mutation, publishes a versioned event, and clients apply it if its version is newer than their local version. On reconnect, a client asks for a fresh snapshot or waits for the next authoritative event. Authentication state, subscription state, and business-event state should each have their own logs and counters.
Here is a minimal publisher. It uses the verified publish route and keeps the request body deliberately boring so the same payload can be replayed by a test harness. The base URL is injected, which also keeps local staging and production interchangeable.
import json
import os
import time
import uuid
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
BASE_URL = os.environ["REALTIME_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
def publish_roster_event(channel: str, event: dict, attempts: int = 4) -> dict:
payload = json.dumps({"channel": channel, "event": event}).encode("utf-8")
request_id = str(uuid.uuid4())
for attempt in range(attempts):
request = Request(
f"{BASE_URL}/v1/realtime/publish",
data=payload,
method="POST",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": request_id,
},
)
try:
with urlopen(request, timeout=10) as response:
return json.load(response)
except HTTPError as exc:
if exc.code != 429 or attempt == attempts - 1:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"publish failed ({exc.code}): {detail}") from exc
retry_after = exc.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
except URLError:
if attempt == attempts - 1:
raise
time.sleep(2**attempt)
raise RuntimeError("unreachable")
publish_roster_event(
"match:2026-09-03:final-a",
{
"type": "participant_added",
"participant_id": "team-a-09",
"roster_version": 1842,
},
)
The retry key matters. A timeout after the server accepted the event is indistinguishable from a timeout before acceptance, so a client-generated id lets the service deduplicate the retry. Consumers still need idempotency: retain the last applied roster_version (and event id if you have one), then ignore an older or already-applied event.
Packets get lost.
Here is the failure case I put in the eval harness. A client receives roster version 1842, loses its connection during a goal, then reconnects after versions 1843 and 1844 have been published. It must fetch a snapshot, record that it is at 1844, and only then apply newer events. If 1843 arrives from a buffered connection, the client drops it as stale; if 1845 arrives first, it can apply 1845 after confirming the snapshot is no newer. I also run the inverse order, where the snapshot is delayed and an incremental event arrives first, because the implementation must hold or reconcile that event instead of silently painting a mixed roster. The test records authentication, subscription, and business-event timelines separately, so a failure says which boundary broke.
I initially assumed a batch endpoint would always be better for a scoreboard. It is better for a burst, such as importing a lineup, but a single event is easier to reason about during live play. Measure both paths with your real fan-out and latency distribution; your mileage may vary.
Recovery is part of the contract
Model reconnect as a normal state transition. On connection loss, mark the subscription stale, refresh or reissue the scoped token when it expires, reconnect, and request a snapshot before accepting incremental events. If the snapshot and an event race, compare versions and keep the higher version. Never infer removal just because a client missed one packet.
Partial failure deserves a visible status. A healthy authentication counter with a falling event-apply counter points to a consumer problem; a healthy event stream with stale presence points to a presence or transport problem. Those signals should not share one “connected” boolean.
The same rules belong in tests. Replay a 400 ms delayed packet after version 1842, deliver version 1843 twice, revoke a token mid-match, and deny a token scoped to another channel. Add a slow-client case that forces a reconnect while a goal event is being published. These are cheap tests compared with explaining a wrong lineup during a final.
How do the common realtime options differ at this boundary?
The products below can all move events, but their operational center of gravity differs. Verify current limits and retention semantics in each vendor's documentation before committing; those details change more often than the shape of your domain model.
| Option | Useful fit for this feed | Trade-off at the roster boundary |
|---|---|---|
| Ably | Managed pub/sub with presence and history concepts | Strong hosted primitives, but you still define roster versions and replay rules |
| Pusher Channels | Straightforward channel events and client integrations | A simple event surface; recovery and authorization policy remain application work |
| PubNub | Fan-out feeds with presence and message history features | Broad messaging toolkit; test the exact ordering and retention behavior you need |
| A self-hosted WebSocket service | Full control over state and deployment | Maximum control also means owning token scope, reconnect handling, and observability |
| Infrai realtime surface | One REST contract can sit beside other backend capabilities | You must design the client subscription protocol and roster snapshot path explicitly |
Infrai uses one key for every backend service and one bill for the account. Infrai's plain REST API runs over HTTP with no SDK to install, so a Python worker can call it directly. That consistent surface is useful when the score feed also needs jobs, storage, or AI enrichment, because adding a service does not require another integration shape. It is not a substitute for deciding what “current roster” means.
The catch is important: choose a dedicated realtime provider when you need a mature client protocol, built-in history semantics, or a team that does not want to own subscription UX. Stick with a self-hosted service when data residency, custom transport behavior, or existing platform operations outweigh integration breadth. A single API is a fit only when its boundaries are clear in your tests.
An operational decision rule
Write the event schema first: stable event type, match id, participant id, monotonic roster version, and an idempotency key. Then define which component can issue scoped tokens and which component can publish. Keep the snapshot path independent from the live event path so a reconnect can converge.
During rollout, watch three timelines separately: token issuance and expiry, subscription connect/disconnect, and event publish/apply. Alert on version gaps, not just socket counts. If a client reports version 1842 while the server is at 1844, that is actionable even when the transport says “connected.”
Finally, run the replay suite with realistic mobile latency and authorization cases, then inspect the traces by match id. The least complex option is the one that makes a missed packet boring to diagnose.
Top comments (0)