DEV Community

SunspireValerius59
SunspireValerius59

Posted on

How to Build Realtime Participant Roster Sync in Node.js: API Boundaries

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
Enter fullscreen mode Exit fullscreen mode

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,
    }
Enter fullscreen mode Exit fullscreen mode

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

Further reading

Top comments (0)