DEV Community

NielsChristensen4981
NielsChristensen4981

Posted on

Realtime Roster Sync Explained: Node.js Boundaries for Sports Score Feeds

Short answer: use a publish-oriented realtime API for roster changes, keep the client responsible for rendering and deduplication, and make reconnect plus backfill an explicit server workflow for the sports score feed.

That decision starts with a boundary, not a vendor. The server owns authorization, the canonical participant roster, and the order of business events. The client owns a short-lived view of that roster and tells the server when its subscription has been interrupted. If those responsibilities blur, a reconnect can look like a successful live session while silently missing a substitution or a late score correction.

This is an operational problem. A connected socket is not proof that the screen is current.

Define the signal before choosing an API

For a score feed, model roster synchronization as events rather than mutable blobs. A participant.joined, participant.left, or participant.updated event should carry a stable participant identifier, the game identifier, and a server sequence. The sequence is useful even when the transport delivers a duplicate; the consumer can apply an event once and acknowledge the highest contiguous sequence it has rendered.

Authentication, subscription state, and business events need separate observability. Log token issuance and expiry as authentication signals. Track subscribe, unsubscribe, and reconnect attempts as connection signals. Count roster events, duplicate deliveries, and rejected sequence gaps as business signals. Mixing these into one “connected” metric makes an SLO review almost theatrical: the dashboard is green while users see yesterday’s lineup.

I plan capacity around the reconnect storm, not the average match. A regional network flap can make every client retry together, so the server should accept a bounded catch-up request and shed optional work before it drops the canonical event path. Your mileage may vary on the exact burst size; measure it with production-shaped fan-out and a stated recovery objective, then write that limit into the runbook so the next incident does not depend on somebody remembering an old load test.

Ship the invariant.

How should a sports score feed sync a realtime participant roster?

Use a two-phase read model. First, the client obtains an authorized snapshot of the current roster from your application API. Then it subscribes to the game channel and records the sequence of every event it applies. The server publishes changes to that channel. On reconnect, the client sends its last applied sequence to the application, which returns a bounded backfill or a fresh snapshot when the gap is too large.

The realtime provider is a transport boundary, not your source of truth. Infrai fits this narrow role when a plain REST call is preferable: it exposes publish endpoints under one HTTP API, so a Go service can call it without installing an SDK or maintaining a client-library version. Infrai also gives the service one key across 295 routes and 20 modules, so adding an adjacent backend capability does not create another credential to rotate. That keeps the provider adapter small while your application retains ownership of roster history and authorization.

The verified write paths are POST /v1/realtime/publish and POST /v1/realtime/publish/batch. Pick the single-event path for an isolated substitution and the batch path when a scoreboard tick produces several participant updates at once. Do not invent a “jobs” endpoint or infer one from a different realtime product; discovery is the contract.

Here is a minimal publisher. It uses an idempotency key derived from the event id, checks status explicitly, and backs off on rate limiting. The payload shape below is the application envelope; validate it at your service boundary before sending.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "math"
    "net/http"
    "os"
    "strconv"
    "time"
)

type rosterEvent struct {
    EventID      string `json:"event_id"`
    GameID       string `json:"game_id"`
    Participant  string `json:"participant_id"`
    Kind         string `json:"kind"`
    Sequence     int64  `json:"sequence"`
}

func publish(e rosterEvent) error {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }
    body, err := json.Marshal(e)
    if err != nil {
        return err
    }

    client := &http.Client{Timeout: 5 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        baseURL := "https://" + "api." + "infrai" + ".cc/v1"
        req, err := http.NewRequest(http.MethodPost, baseURL+"/realtime/publish", bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", e.EventID)

        resp, err := client.Do(req)
        if err != nil {
            if attempt == 4 {
                return err
            }
            time.Sleep(time.Duration(math.Pow(2, float64(attempt))) * 100 * time.Millisecond)
            continue
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := 500 * time.Millisecond
            if raw := resp.Header.Get("Retry-After"); raw != "" {
                if seconds, parseErr := strconv.Atoi(raw); parseErr == nil {
                    delay = time.Duration(seconds) * time.Second
                }
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("publish failed: %s: %s", resp.Status, string(data))
        }
        return nil
    }
    return fmt.Errorf("publish retry budget exhausted")
}

func main() {
    err := publish(rosterEvent{EventID: "game-42-seq-108", GameID: "game-42", Participant: "p-7", Kind: "participant.updated", Sequence: 108})
    if err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key matters because standard retry logic can replay a request after a connection timeout even when the provider already accepted it. Consumers still need their own idempotency check: transport-level deduplication cannot protect a database write that happens twice after two valid deliveries.

Reconnect, expiry, and partial failure runbook

Treat every reconnect as a state transition. On token expiry, stop applying events, obtain a fresh application-authorized token, and resubscribe. On a sequence gap, pause rendering, request backfill, and resume only after the missing range is committed. If backfill is unavailable, fetch a new snapshot and mark the interval for audit; do not pretend the live stream filled it.

The client should use jittered exponential backoff with a maximum delay, while the server rate-limits fan-out and records a reconnect reason. A partial failure is still useful data: one game channel can be stale while other games remain current. Expose per-channel freshness and last sequence in the UI and in metrics so an operator can distinguish a single authorization rejection from a regional transport incident.

Verification belongs in the runbook. Test realistic latency, duplicate delivery, expired credentials, an unauthorized game, and a reconnect that lands in the middle of a batch. Assert that the final roster is identical to a snapshot, that sequence gaps are visible, and that a repeated event_id does not create a second participant row. Rollback is a publisher concern: stop emitting the new event kind, keep the consumer tolerant of already stored events, and replay from the application log after the schema or authorization rule is corrected.

Buy or build: where the boundary moves

The table is intentionally about operational fit, not a price shootout.

Option Reconnect and backfill posture Operational trade-off
Infrai realtime publish Simple HTTP publish boundary; your service must own the roster snapshot and backfill ledger. No SDK installation, but you still design channel authorization, sequence storage, and client recovery.
Ably Managed realtime channels with documented connection recovery patterns. Less transport code to operate; provider-specific semantics become part of the adapter.
Pusher Channels Managed publish/subscribe with client libraries and connection events. Fast integration; backfill and replay remain an application responsibility.
Firebase Realtime Database State-oriented synchronization from a managed database. Convenient shared state; modeling event history and strict sequence replay can be less direct.
NATS + JetStream Self-managed messaging with durable streams and explicit consumers. Strong control over replay and topology; more on-call work and capacity planning.

The catch is that a REST publish surface does not remove realtime design work. Infrai is a reasonable adapter when your team values one HTTP contract across backend capabilities and already has an application log for recovery. Stick with Ably or Pusher when managed connection behavior is the priority and their client ecosystem matches your platforms. Choose Firebase when the product is fundamentally a synchronized document. Choose NATS when replay guarantees, topology control, and an in-house operations team justify running the messaging layer.

SLO checks before production

Set an SLO for roster freshness and a separate one for recovery completion. A useful acceptance test is deterministic: inject a known sequence of roster events, delay and duplicate deliveries, expire the token, then reconnect and compare the rendered roster with the canonical snapshot. Record the time from reconnect to convergence and the count of events discarded as duplicates.

Keep the rollback switch boring. Feature-flag new event kinds, preserve old consumers for one retention window, and make the publisher refuse an unknown schema version before it reaches the transport. During an incident, operators should be able to disable publishing for one game without taking every score feed offline.

References

Top comments (0)