Short answer: keep the roster authoritative on the server, issue narrowly scoped room tokens, and replay a versioned snapshot after every reconnect. A sports score feed should treat participant presence as data with a revision, not as a side effect of a socket connection.
Start with the bill, not the socket
The largest retention cost in a score feed is usually not the handshake. It is keeping every transient participant event and replay buffer forever. A practical budget starts with three quantities: active rooms, roster mutations per room, and the number of revisions retained for reconnect.
For example, a room with 20 participants that emits 10 join/leave changes per minute creates 14,400 mutations in a day. Keeping a seven-day event log means 100,800 records before indexing overhead. A compact snapshot plus a short mutation window changes the storage shape: retain the current roster and perhaps the last 200 revisions, then expire older history. The trade-off is explicit. A late client may receive a fresh snapshot instead of the exact sequence of events it missed.
That is the retention decision I would write into the design review: preserve enough history to explain a score update, but stop treating presence chatter as an audit ledger. When an incident needs a full timeline, export it to a separate, cheaper log stream with its own retention policy.
How should realtime participant roster sync cross API boundaries?
A reconnecting client can trust a server-issued room token and a monotonic roster revision. It cannot trust its own last-known membership, a stale browser tab, or a message that arrives after a network flap.
Give the token a room identifier, a participant identifier, and capabilities such as roster:read or roster:write. Keep score publishing outside that token unless the participant actually needs it. The server checks the token on every subscription and mutation; the client never decides that it is still a referee, scorer, or viewer because it was one five minutes ago.
A revision makes replay deterministic. If the client has revision 418 and the server's next event is 419, apply it. If the next event is 421, request a snapshot. Do not guess what happened to 420.
from dataclasses import dataclass
@dataclass(frozen=True)
class RosterState:
revision: int
participants: dict[str, str]
def reconcile(local: RosterState, incoming: dict) -> tuple[str, RosterState | None]:
revision = int(incoming["revision"])
if revision == local.revision + 1:
participants = dict(local.participants)
event = incoming["event"]
if event["kind"] == "joined":
participants[event["id"]] = event["role"]
elif event["kind"] == "left":
participants.pop(event["id"], None)
return "apply", RosterState(revision, participants)
if revision <= local.revision:
return "ignore_duplicate", None
return "request_snapshot", None
The small state machine matters more than the transport. WebSocket, Server-Sent Events, or a WebRTC data channel can carry the same contract. WebRTC's recommendation describes data channels and connection state, but it does not define your roster authorization model; that boundary remains application code.
Define API boundaries before choosing a transport
Use separate operations for token minting, snapshot reads, and event delivery. A minimal HTTP surface might look like this:
from typing import TypedDict
class TokenRequest(TypedDict):
room_id: str
participant_id: str
capabilities: list[str]
def mint_room_token(request: TokenRequest) -> dict:
allowed = {"roster:read", "roster:write"}
capabilities = sorted(set(request["capabilities"]) & allowed)
return {
"room_id": request["room_id"],
"participant_id": request["participant_id"],
"capabilities": capabilities,
"expires_in": 300,
}
The endpoint that returns a snapshot should include revision, participants, and a server timestamp. The event channel should carry the same revision field. Keep mutation and delivery paths independently observable so a backlog in delivery does not look like an authorization failure.
I once diagnosed a roster that appeared to resurrect a player after reconnect. The cause was ordinary: a mobile client retried a join with an old token while two event streams were open. First the stale stream delivered a delayed left; then the retrying stream delivered joined; finally a third browser replayed the old join from its local queue. The dashboard showed three different participant counts for the same match, and the discrepancy survived a page refresh because the client persisted its last event instead of its last revision. The fix was to make joins idempotent by (room_id, participant_id), close the previous stream when its connection lease expired, and persist only the authoritative revision. The log showed code 401 for the stale token, which was the useful signal; silently accepting it would have made the score feed lie.
Test in a matrix, not with one happy-path browser. Drop the connection between snapshot and subscription. Duplicate event 419. Deliver 421 before 420. Expire a token while the socket remains open. Change a participant's role while a second device is connected. Assert that each case ends in either an applied revision or an explicit snapshot request.
Load tests should vary room fan-out and reconnect storms separately. A feed can handle steady traffic yet fall over when a goal triggers thousands of viewers to reconnect at once. Measure time to consistent roster, snapshot bytes, authorization denials, and duplicate suppression. Your mileage may vary on the right replay-window size; production traces, not a generic benchmark, should set it.
The catch is operational complexity. A self-hosted broker gives control over retention and identity, but your team owns failover, backpressure, and key rotation. A managed realtime service can reduce that work, yet its token model or replay semantics may not map cleanly to per-role sports rooms. Stick with the option whose failure signals your on-call team can inspect.
Measure twice.
References
- W3C WebRTC Recommendation: https://www.w3.org/TR/webrtc/
- RFC 6750, The OAuth 2.0 Authorization Framework: Bearer Token Usage: https://www.rfc-editor.org/rfc/rfc6750
- RFC 7662, OAuth 2.0 Token Introspection: https://www.rfc-editor.org/rfc/rfc7662
Top comments (0)