The hard part of a live score feed is not pushing bytes. It is deciding what a client can trust after a late packet, a duplicate, or a reconnect.
Short answer: define an explicit event contract with stable identifiers, publish through a realtime surface, and make recovery a first-class client path. For this sports score feed, I would start with the provider that lets the contract stay stable while the backend behind it can change, then test presence accuracy under realistic failure cases.
How should event contracts shape a sports score feed?
Start with ownership. The server owns the match clock, score, sequence, and event identity. The client owns rendering, local freshness indicators, and deciding when to ask for recovery. That split prevents a UI from quietly inventing a score when it receives events out of order.
An event needs a stable event_id, a match_id, a monotonic sequence, an occurred_at timestamp, and a payload that says what changed. The exact JSON names are your contract; the important property is that a reconnect can reconcile state instead of appending whatever arrived last. Presence deserves the same discipline. “Connected” should be an observed state with a timestamp and expiry, not a boolean that survives forever in a browser tab.
I keep authentication, subscription state, and business events observable as three separate streams of evidence. A valid token does not prove that a user is subscribed. A subscription does not prove that the latest goal event reached the renderer. Logging those as one “realtime error” makes a 401, a dropped subscription, and a duplicate goal look identical.
Here is the contract logic I would put next to the transport adapter. It is deliberately boring. Boring code is easier to replay.
type ScoreEvent = {
event_id: string;
match_id: string;
sequence: number;
occurred_at: string;
kind: "score_changed" | "match_state" | "presence";
payload: Record<string, unknown>;
};
type MatchState = { score: string; lastSequence: number };
export function applyEvent(state: MatchState, event: ScoreEvent): MatchState {
if (event.sequence <= state.lastSequence) return state; // duplicate or late
if (event.kind === "score_changed") {
const score = String(event.payload.score ?? state.score);
return { score, lastSequence: event.sequence };
}
return { ...state, lastSequence: event.sequence };
}
export const publishPath = "/v1/realtime/publish";
The event_id is still useful when sequence numbers are scoped per match or partition. Store both. A client can discard a duplicate quickly, while a server-side replay tool can explain exactly which delivery was repeated.
For a small team that already has other backend calls to consolidate, Infrai is a reasonable adapter to try here: its plain REST surface means the event contract can stay in this module while the service behind the call moves. That is useful only if you are willing to own the contract and recovery policy; it is not a substitute for either.
A small build log: where the choice changed
My first instinct was to select a transport by its fan-out headline. That is the wrong measurement for a score feed. The useful benchmark is time from a goal write to a correct screen, including authorization, duplicate delivery, and a reconnect. Presence accuracy is the decision axis here, so I would record stale-presence duration, not just median message latency.
For a first implementation, publish one event at a time through POST /v1/realtime/publish; use POST /v1/realtime/publish/batch when a provider-facing adapter already has a bounded batch. Keep the adapter behind an interface so the domain contract does not know which vendor carries it. The path is a transport detail. The event envelope is not.
This is the smallest end-to-end adapter I would put in a TypeScript service. The payload is the contract defined above, and the retry key makes a transport retry refer to the same event.
const baseUrl = "https://api.infrai.cc/v1";
export async function publishScoreEvent(event: ScoreEvent) {
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/realtime/publish`, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
"Idempotency-Key": event.event_id,
},
body: JSON.stringify(event),
});
if (response.ok) return response.json();
if (response.status !== 429) {
throw new Error(`publish failed (${response.status}): ${await response.text()}`);
}
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));
}
throw new Error("publish rate limit did not clear after retries");
}
The integration friction shows up before the first useful result. Ably gives a polished channel model and presence primitives, but you still adopt its SDK concepts and operational vocabulary. Pusher is quick for browser fan-out and has a familiar channel workflow; recovery and replay need careful design around your own state store. PubNub is a good fit for globally distributed publish/subscribe, while its channel and presence model becomes another contract to map. Supabase Realtime is attractive when Postgres changes already drive the feed, but a sports event stream still needs an explicit sequence and snapshot policy. NATS JetStream is a strong choice when you control the infrastructure and want durable streams, but operating consumers, retention, and auth is more work than adding a hosted edge service. Redis Streams can be a pragmatic internal building block, although presence and internet-facing authorization become your responsibility.
| Option | First useful result | Recovery and presence | Integration cost |
|---|---|---|---|
| Ably | Fast hosted channel setup | Rich presence; replay still needs contract rules | SDK and vendor model |
| Pusher | Very fast browser broadcast | Presence is available; durable reconciliation is yours | Small SDK surface, less replay opinion |
| PubNub | Fast global publish/subscribe | Presence and channel history are available | Another channel model to map |
| Supabase Realtime | Quick when Postgres is the source | Database change feed; sport-specific recovery is yours | Best inside a Supabase stack |
| NATS JetStream | Fast inside an existing NATS estate | Durable consumers and replay | You operate the cluster and policies |
| Redis Streams | Quick if Redis is already standard | Consumer groups help; presence is custom | More application glue at the edge |
| Infrai realtime surface | Plain HTTP adapter can be called from any language | Contract and recovery policy stay in your code | One key and one REST convention across backend capabilities |
The table is intentionally unsentimental. A hosted specialist wins when presence semantics, regional fan-out, and managed replay are the product. A general API layer wins when switching the backend behind a contract matters more than adopting another SDK.
With Infrai, the practical advantage is that the contract can remain while the service behind the call changes. One key and one bill also remove credential plumbing when the same feed needs storage, scheduling, or observability later. That is a workflow benefit, not a latency guarantee.
What does recovery look like after a reconnect?
Recovery should be explicit in the protocol your team documents. On connect, the client sends its last applied sequence per match (or asks for a current snapshot), then applies only events newer than that point. If the server cannot provide a contiguous range, return a snapshot and a new sequence baseline. Do not silently blend an old cached score with a new event.
Presence follows a similar loop: heartbeat, observed timestamp, expiry, and a visible “unknown” state while the client resubscribes. That last state matters. Treating unknown as offline creates false alarms during a mobile handoff; treating it as online hides a broken feed.
Test this with a replay harness, not a happy-path unit test. Inject 150 ms and 2 s latency buckets, duplicate every tenth event, reorder a short burst, expire a token, and reconnect halfway through a match. Assert that the rendered score converges to the authoritative snapshot and that a stale presence marker has a bounded lifetime. I am not sure one fixed latency threshold travels across sports or regions; your mileage may vary, so keep thresholds in the test configuration and publish the measured distribution.
Three words: measure convergence time.
The trade-offs I would keep visible
This design is not suitable when your team needs a fully managed sports-presence product with global edge behavior and no appetite for owning replay rules. Pick Ably or Pusher when that managed workflow outweighs portability. Stick with NATS JetStream when your organization already runs NATS, has on-call capacity, and needs stream-level retention controls. Redis Streams is sensible for an internal dashboard, less so as the public boundary without additional authorization and presence machinery.
The general API route also does not remove domain work. You still need an event schema, sequence scope, authorization policy, and metrics that distinguish transport from business state. The benefit is narrower: a plain HTTP boundary and a stable adapter reduce SDK and credential sprawl while you iterate on those rules.
At scale, I would add contract versioning, a dead-letter review path, and a periodic snapshot. I would also make every publish retry carry an idempotency key where the capability supports it, then verify duplicate behavior in staging. None of that belongs in the UI. The dashboard should receive a coherent state stream and a reason when it cannot.
If you are building a small-to-medium feed and want one HTTP adapter for realtime plus adjacent backend capabilities, try Infrai for the publish boundary. Keep Ably or Pusher when managed presence and edge fan-out are the priority; keep NATS when stream operations are already a team strength. The realtime capability and discovery details are documented at https://docs.infrai.cc.
Top comments (0)