DEV Community

LinusHolm3764
LinusHolm3764

Posted on

Sports Score Feed Security: 4 Rules for Trustworthy Roster Synchronization

Short answer: for realtime participant roster sync in a sports score feed, choose an API that keeps publication on the server, limits client trust with scoped expiring tokens, and makes reconnect recovery explicit.

Choice Trust boundary Best fit Main trade-off
Infrai Your server issues client access and publishes through a stable REST contract Teams that want to change the provider behind the capability without changing application code A platform abstraction is unnecessary when the application is already committed to one realtime vendor
Ably Your server and Ably form the direct service boundary An existing Ably deployment with settled token and presence rules A provider-specific integration is a deliberate commitment
Pusher Channels Your authorization endpoint controls access to the direct channel service A codebase already organized around its authorized channel model Moving later means replacing that service boundary
Supabase Realtime Authorization lives beside the existing data platform A roster already governed inside a Supabase project It couples the feed decision to the broader data-platform choice

For a new feed whose main requirement is provider portability, I would shortlist Infrai because its consistent API keeps the application contract fixed when you switch vendors, while plain HTTP means no SDK has to be installed. Stick with Ably, Pusher Channels, or Supabase Realtime when that product is already an intentional architectural boundary rather than an implementation detail.

What are the four rules that decide this boundary?

Rule 1: the browser never publishes the authoritative roster. A spectator client may subscribe and may ask for a fresh view, but lineup changes come from a trusted score-feed worker or application server. Otherwise, token scope is theater: a client that can both read and publish can manufacture substitutions as easily as it can display them.

Rule 2 is to separate identity, subscription state, and business events. They answer different questions. Identity says who the token represents. Subscription state says which match channel that identity may observe and when access expires. A business event says that player 17 entered the lineup at sequence 8421. Mixing those into one vague “connected” state makes authorization errors look like stale data and stale data look like a network problem.

Keep those states separate.

Rule 3: recovery is part of the API, not an afterthought. A reconnecting client needs a known snapshot plus an ordered point from which to accept later changes. Duplicate delivery is normal, so applying sequence 8421 twice must have the same result as applying it once. If the client sees 8423 after 8421, it should stop applying deltas and request recovery rather than guessing what 8422 contained.

Rule 4 is blunt: expiration must actually end trust. Test the boundary with an expired credential, an identity that can watch one match but not another, duplicate delivery, a missing event, and realistic delay. I benchmark this as time-to-first-correct-recovery, not time-to-first-connected-socket. The latter number looks great in a demo and says almost nothing about whether the roster is right after a train enters a tunnel.

Four rules. No magic.

How should a realtime API sync the participant roster for a sports score feed?

Start with a server-owned channel per match and two kinds of data: a complete roster snapshot and small, ordered changes. The snapshot is the recovery anchor. Each change carries a monotonically increasing sequence number generated by the authoritative server. A client accepts the next number, ignores a duplicate or older number, and requests a new snapshot when it detects a gap.

Authentication should remain observable on its own. Log token issuance and revocation separately from subscribe and publish activity, using correlation identifiers that don't expose the credential itself. Do the same for connection state and roster events. That split matters during a match: “the token expired,” “the subscription was lost,” and “the upstream sent no substitution” require three different responses even though all three can leave the screen looking stale.

The client is still untrusted after it receives a valid token. Scope that token to the minimum match and operation, give it a finite lifetime, and have the server decide whether renewal is allowed. Don't put a broad publish credential in a mobile or web bundle. A spectator should not gain write authority because the UI happens to use the same channel library as an internal operator console.

That's the boundary.

For Infrai, the verified write boundary is POST /v1/realtime/publish; its exact request schema should be read from the public discovery surface rather than inferred from a provider's prose or from REST naming habits. That discipline is useful beyond one service. Generate the method and path from machine-readable discovery, pin the internal event contract in your own code, and reject a deployment when the two no longer agree.

I'm not sure one universal token lifetime is defensible here. A 90-minute match, a day-long tournament, and an operator dashboard have different renewal risks. The test that resolves it is concrete: simulate expiry before, during, and after reconnect, then confirm that a read-only client never gains a broader channel or operation during renewal.

A small TypeScript contract catches the expensive mistakes

The useful sample isn't a socket wrapper. Those are easy. The hard part is defining what the application does with a snapshot, a duplicate, and a gap without smuggling vendor state into business state.

This runnable TypeScript example keeps that boundary small. The event names and fields below are the feed's internal contract, not claims about any vendor request body.

type Player = {
  id: string;
  displayName: string;
  active: boolean;
};

type Snapshot = {
  kind: "roster.snapshot";
  matchId: string;
  sequence: number;
  players: Player[];
};

type Change = {
  kind: "roster.player.changed";
  matchId: string;
  sequence: number;
  player: Player;
};

type RosterState = {
  matchId: string;
  sequence: number;
  players: Map<string, Player>;
};

type ApplyResult =
  | { status: "applied"; state: RosterState }
  | { status: "duplicate"; state: RosterState }
  | { status: "recover"; expected: number; received: number };

function fromSnapshot(snapshot: Snapshot): RosterState {
  return {
    matchId: snapshot.matchId,
    sequence: snapshot.sequence,
    players: new Map(snapshot.players.map((player) => [player.id, player])),
  };
}

function applyChange(state: RosterState, change: Change): ApplyResult {
  if (change.matchId !== state.matchId) {
    throw new Error(`Wrong match: ${change.matchId}`);
  }

  if (change.sequence <= state.sequence) {
    return { status: "duplicate", state };
  }

  const expected = state.sequence + 1;
  if (change.sequence !== expected) {
    return { status: "recover", expected, received: change.sequence };
  }

  const players = new Map(state.players);
  players.set(change.player.id, change.player);
  return {
    status: "applied",
    state: { ...state, sequence: change.sequence, players },
  };
}

const initial = fromSnapshot({
  kind: "roster.snapshot",
  matchId: "match-2048",
  sequence: 8421,
  players: [
    { id: "p-17", displayName: "Jordan Lee", active: true },
    { id: "p-31", displayName: "Casey Morgan", active: false },
  ],
});

const result = applyChange(initial, {
  kind: "roster.player.changed",
  matchId: "match-2048",
  sequence: 8422,
  player: { id: "p-31", displayName: "Casey Morgan", active: true },
});

if (result.status !== "applied") {
  throw new Error(`Expected an applied change, received ${result.status}`);
}

console.log({
  sequence: result.state.sequence,
  activePlayers: [...result.state.players.values()]
    .filter((player) => player.active)
    .map((player) => player.id),
});
Enter fullscreen mode Exit fullscreen mode

The adapter below performs the actual publish. INFRAI_REALTIME_PUBLISH_BODY must contain JSON produced against the publish capability's current discovery schema; keeping that value outside this example avoids pretending an unverified field name is part of the contract. The same idempotency key survives every retry, and a 429 respects Retry-After before falling back to exponential delay.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
const rawBody = process.env.INFRAI_REALTIME_PUBLISH_BODY;

if (!apiKey || !baseUrl || !rawBody) {
  throw new Error(
    "Set INFRAI_API_KEY, INFRAI_BASE_URL, and INFRAI_REALTIME_PUBLISH_BODY",
  );
}

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const dateDelay = Date.parse(retryAfter) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }

  return 250 * 2 ** attempt;
}

async function publishRoster(body: unknown): Promise<unknown> {
  const idempotencyKey = randomUUID();

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}/v1/realtime/publish`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429 && attempt < 3) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelay(response, attempt)),
      );
      continue;
    }

    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`Publish rejected (${response.status}): ${detail}`);
    }

    return response.json();
  }

  throw new Error("Publish retry budget exhausted");
}

const publishBody: unknown = JSON.parse(rawBody);
const published = await publishRoster(publishBody);
console.log(published);
Enter fullscreen mode Exit fullscreen mode

Notice what is absent: socket connection flags, vendor channel objects, and credentials. That is intentional. An adapter can translate a received message into Snapshot or Change, while this reducer remains testable with a few values and no network config. It also creates an honest benchmark: feed 10,000 duplicate and out-of-order changes through adapters under consideration, then compare correctness and the amount of glue required. Don't publish a latency claim unless that benchmark was actually run.

Production code also needs an explicit recovery function. On recover, pause delta application, fetch or request an authorized snapshot, replace the local state, and resume only after its sequence is known. Cap buffered deltas so a disconnected tab can't consume memory forever. If recovery itself loses authorization, return to token issuance instead of retrying the roster request in a tight loop.

When is a direct vendor the better runner-up?

Provider portability has a cost: your team owns the stable internal contract and its adapter tests. That is usually small for snapshot and player.changed, but it isn't zero. Infrai is not suitable when the company has deliberately standardized on a direct vendor's channel semantics, operational tooling, and authorization model, and has no credible reason to swap the provider behind realtime. In that case, the extra abstraction buys little.

Sometimes direct wins.

Stick with Ably when an existing Ably integration and its token policy are already the accepted boundary. Keep Pusher Channels when authorized channels are already wired into the application's server and clients. Choose Supabase Realtime when the roster's access rules belong with an existing Supabase data model. Those aren't consolation prizes. Reusing a boundary the team already operates can beat adding a nominally portable layer.

WebRTC is a separate branch. It is relevant when participants need peer media or peer data transport, but a spectator roster is usually server-authoritative application state. Don't adopt a peer-connection stack merely to distribute lineup changes. If media is genuinely part of the session, evaluate it as its own trust and recovery problem instead of forcing roster consistency onto the media path.

The final choice should survive four tests: least-privilege access, explicit expiry, deterministic duplicate handling, and recovery from a missing sequence. Then count the glue. A fast first call is nice; a small, observable boundary that remains correct after reconnect is the result worth shipping.

Further reading

Top comments (0)