DEV Community

TrippDonovan5461
TrippDonovan5461

Posted on

Priority Notifications for Sports Feeds: Managed Realtime vs Build-Your-Own Boundaries

Short answer: use a managed realtime channel when the score feed needs priority notifications across many clients, and keep the API boundary responsible for authorization, ordering, and recovery. Build on WebSocket or WebRTC primitives when your team needs transport-level control and can own the operational work.

A goal is easy to state: a touchdown, red card, or final whistle should reach the right fan quickly. The hard part is deciding what a client is trusted to request, what the server promises to deliver, and how both sides recover after a dropped connection. In a B2B SaaS editor that lets analysts annotate a live game together, the same boundary also carries collaborative cursor updates. Those cursors are low priority; a score change is not.

A field guide to the options

Start with the contract, not the vendor. Define event names, stable identifiers, audience scopes, and who may publish each priority. Then compare the operational shape:

Option Pick it when Strength Trade-off
Managed realtime service (Ably, Pusher, or similar) You need fan-out, presence, and reconnect handling quickly Delivery primitives and dashboards are ready Provider-specific limits and pricing models become part of your design
Firebase Realtime Database / Firestore listeners Your product already uses Firebase auth and data rules One ecosystem for state and clients Notification priority and transport semantics follow Firebase's data model
Self-managed WebSocket service You need custom routing, residency, or protocol control Full control over connection and retention behavior You own scaling, replay, expiry, and alerting
Infrai realtime API You want a plain HTTP publish boundary while retaining the option to change backends One REST API and one key can cover multiple backend capabilities; the contract stays stable while the provider behind it changes You still need to design client subscriptions, authorization, and replay policy

The table is a decision aid, not a benchmark. Ably and Pusher are strong fits for managed fan-out. Firebase is a sensible choice when your source of truth already lives there. A self-managed service wins when transport behavior is itself a product requirement.

How should API boundaries shape priority notifications for a sports score feed?

Draw the system as two boxes. The server box authenticates a publisher, validates the event, assigns a stable event_id, and records the feed version. The client box subscribes with a scoped token, deduplicates by event_id, and asks for recovery after a reconnect. Between them sits a narrow publish API. That narrowness is a feature: fewer verbs mean fewer authorization surprises.

For each event, carry game_id, event_id, sequence, priority, and occurred_at. A client can then apply sequence monotonically and ignore an older duplicate. Do not let a browser decide that an ordinary cursor movement is a critical alert; the server maps domain facts to priority.

Expiry is normal. A token issued for one league or tenant should stop authorizing publishes after its declared lifetime. Reconnects are normal too. Keep the last acknowledged sequence on the client, and make the recovery request explicit rather than silently assuming that every missed notification will be replayed.

Partial failure deserves a visible state. If one regional consumer is behind, expose that lag to operations and keep the event identifier stable across retries. Your alert should distinguish a rejected authorization attempt from a duplicate delivery; they require different fixes.

A minimal publish boundary in TypeScript

This example sends one priority event through the verified publish route. The idempotency key is derived from the event ID, so a retry cannot create a second logical score change. The response body is checked and returned to the caller.

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

if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");

type ScoreEvent = {
  event_id: string;
  game_id: string;
  sequence: number;
  priority: "critical" | "normal";
  occurred_at: string;
  payload: { home: number; away: number; status: string };
};

async function publishScore(event: ScoreEvent): Promise<unknown> {
  const headers = {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
    "Idempotency-Key": event.event_id,
  };

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}/realtime/publish`, {
      method: "POST",
      headers,
      body: JSON.stringify({
        event_type: "score.updated",
        channel: `game:${event.game_id}`,
        data: event,
      }),
    });

    if (response.ok) return response.json();

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

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

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

await publishScore({
  event_id: "game-8842-seq-109",
  game_id: "8842",
  sequence: 109,
  priority: "critical",
  occurred_at: new Date().toISOString(),
  payload: { home: 27, away: 24, status: "final" },
});
Enter fullscreen mode Exit fullscreen mode

The route is intentionally small. Publishing is the server responsibility; issuing a client token, choosing scopes, and subscribing belong in your identity and client layers. If a batch of independent games is flushed together, the API also exposes /v1/realtime/publish/batch; keep each item's event_id stable and apply the same consumer deduplication rule.

Testing recovery and knowing the limits

Happy-path latency is not enough. Build a test matrix that injects 800 ms delay, duplicate delivery, an expired token, and a reconnect after sequence 109. Assert that the UI shows one score change, never applies sequence 108 after 109, and marks the feed as recovering while it catches up.

I like one deliberately boring test: kill the socket immediately after the client acknowledges an event. If the next connection starts from the stored sequence and converges to the server's state, the boundary is doing useful work. If it merely hopes the transport retries, you have hidden a data-loss decision.

For observability, log game_id, event_id, sequence, authorization scope, delivery attempt, and request ID. Emit counters for publish rejects, duplicate applies, expired tokens, and recovery duration. Alerts should page on sustained recovery failures, not on a single reconnect. In a realistic run, send a burst of final-score events while a client is offline, then reconnect it after a token refresh; the expected result is a single, ordered state per game, with a visible recovery marker during the gap and no duplicate toast when the same event arrives twice. That test also catches a subtle boundary mistake: treating transport acknowledgement as business acknowledgement. A socket can confirm receipt while the application still has not committed the event, so the server-side sequence and client-side applied sequence must remain separate fields.

Ship it.

The catch is that a publish API does not define your subscription protocol or retention policy. It is not suitable when you need a custom media transport, strict on-premise control, or a replay system whose semantics are the core product. Stick with self-managed WebSockets when those constraints outweigh the maintenance cost; choose Firebase when co-locating notifications with Firebase state removes more complexity for your team.

Your mileage may vary on ordering guarantees across regions, so document the scope of sequence (per game, per channel, or global) and test that assumption in production-like conditions. Keep the contract explicit, keep identifiers stable, and let the least complex option earn its place.

References

Top comments (0)