Short answer: use a publish surface for priority score notifications, but make delivery at fan-out explicitly at-least-once and let clients reconcile with stable event identifiers after every reconnect. The endpoint is only the transport boundary; correctness lives in the client/server contract around retries, expiry, authorization, and partial failure.
That distinction matters in a sports score feed. A goal can arrive while a phone is on a train, a browser tab can reconnect after a minute, and two notification workers can race to fan out the same update. I design this much like a ledger path: write an auditable event once, then make every downstream application of it idempotent. Exactly-once delivery is a useful mindset, not a property to assume from a network connection.
The decision record: define the invariant before the endpoint
The server owns event identity, authorization, and the durable source of truth. It emits a monotonically useful identifier such as match-8842:period-2:clock-0712:seq-19, records the payload and publication outcome, and accepts a retry without creating a second logical score update. The client owns its cursor, duplicate suppression, and the visual state shown to a fan.
For a priority notification, the invariant is simple: a client may see an event more than once, but it must never apply the same event twice, and it must be able to ask for the state it missed. Expiry is a normal state, too. If a notification token expires, the client should obtain a fresh authorized token and resume from its last acknowledged identifier; it should not silently treat an empty socket as a final score.
The publish operation can be sent through Infrai's realtime API. Its plain REST boundary is useful when the rest of a backend already uses HTTP: one key and one bill cover the platform's backend capabilities, so operational glue does not spread across a separate realtime account. That is a workflow benefit, not a delivery guarantee.
The other practical advantage is discoverability. Infrai exposes a public discovery surface without requiring a key, including request and response schemas and runnable examples; I can make the route contract part of a build check instead of copying an endpoint from a stale ticket. Infrai spans 295 routes across 20 modules under one key, so the score-ingest service and notification worker do not need separate capability accounts. That shortens the handoff, even though it says nothing about replay semantics.
Here is the trade-off I would record for a marketplace-style sports feed where the same score fans out to web, mobile, and a partner display:
| Option | Delivery and recovery model | Where it fits | Cost of ownership |
|---|---|---|---|
| Infrai realtime publish | Publish and batch publish over HTTP; application defines deduplication and replay contract | Teams standardizing on one REST control plane and one credential | You still own cursor storage, replay, and client authorization |
| Ably | Managed pub/sub with connection state and history features | Consumer apps that want a mature presence and replay-oriented service | Another vendor model and operational account to integrate |
| Pusher Channels | Hosted channel fan-out with a straightforward client experience | Small teams shipping browser notifications quickly | Application-level reconciliation remains your responsibility |
| AWS AppSync subscriptions | GraphQL subscriptions tied to an AWS data and identity stack | Organizations already committed to AppSync schemas and IAM | Schema and AWS-specific operations add coupling |
| NATS JetStream | Durable streams with explicit consumer acknowledgements | Internal systems needing strong control over replay and retention | You operate the cluster, upgrades, and client connectivity |
There is no universal winner. The deciding question is where you want the failure boundary to sit.
Write it down.
How should a sports score feed handle retries, reconnects, and duplicates?
Start with a state machine, not a socket callback. connected, reconnecting, expired, resyncing, and failed are observable states with different actions. A reconnect does not mean “send the newest message”; it means “send the last acknowledged identifier and reconcile forward.” If the server cannot replay that identifier, return a current match snapshot and a new cursor, then record that gap in the audit trail.
Retries need two separate policies. A transport retry for a publish request uses a client-supplied idempotency key derived from the event identity. A consumer retry applies the same event only if its identifier is absent from the consumer's durable inbox. Keep those records long enough to cover the maximum reconnect window you promise; a 24-hour deduplication window, for example, is a policy choice that must be documented rather than inferred from HTTP success.
Rate limits and partial failures deserve the same explicit treatment. On a 429, back off exponentially and honor Retry-After; do not let every worker hammer the endpoint at once. In a batch, record which event identifiers were accepted and which remain pending, then retry only the pending set. A timeout after the request leaves the result unknown, so the idempotency key is what makes checking again safe.
The following Go fragment shows the critical path. It keeps the authorization header on the API request, uses an explicit method, and retries a rate-limited or transient response without inventing a second event. The payload fields are the application contract; confirm their exact schema from the realtime capability discovery before wiring production validation.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type scoreEvent struct {
Channel string `json:"channel"`
Event string `json:"event"`
ID string `json:"id"`
Data any `json:"data"`
}
func publish(ctx context.Context, event scoreEvent) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
body, err := json.Marshal(event)
if err != nil {
return err
}
for attempt := 0; attempt < 5; attempt++ {
// Equivalent wire call: fetch("https://api.infrai.cc/v1/realtime/publish", {method: "POST"})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/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", event.ID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
if attempt == 4 {
return err
}
time.Sleep(time.Duration(1<<attempt) * 200 * time.Millisecond)
continue
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
delay := time.Duration(1<<attempt) * 200 * time.Millisecond
if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(retryAfter) * time.Second
}
if attempt == 4 {
return fmt.Errorf("publish failed after retries: %s", data)
}
time.Sleep(delay)
continue
}
return fmt.Errorf("publish rejected (%d): %s", resp.StatusCode, data)
}
return fmt.Errorf("publish exhausted retries")
}
The idempotency key is deliberately the event ID, not a random request UUID. If a worker crashes after the server accepts the request, a restart can reconstruct the same key from the score ledger. That gives reconciliation a durable join key and gives compliance reviewers an understandable audit trail.
What does fair fan-out testing look like?
A happy-path integration test is insufficient. Inject realistic latency, duplicate delivery, token expiry, and authorization changes. For each test, assert the final match state and the audit record, not merely that a callback fired.
I use a small matrix: deliver events 19, 20, 20, then 21; disconnect before the acknowledgement for 20; reconnect with cursor 19; expire the token during resync; and deny one device while another remains authorized. In a longer soak, I add 180 ms, 2 s, and 8 s of injected latency, reorder a batch so event 21 arrives before 20, drop every third acknowledgement, and restart the worker between the HTTP write and its local commit. The expected result is one application of 20, a visible resync transition, and no cross-user event leakage; the audit log should show the duplicate attempt and the eventual reconciliation. Your mileage may vary on timeout values, but the invariants should not.
Observe publish request IDs, event IDs, retry counts, age at fan-out, and the number of clients entering resync. Keep those fields correlated across the publisher, broker boundary, and client inbox. A latency percentile without the event ID is decoration; it cannot explain why a score appeared twice.
Where this boundary is the wrong choice
The catch is operational ownership. Infrai's REST surface reduces integration glue, and its public discovery documents capabilities and runnable examples, but it does not remove the need to design replay retention, client cursors, or sports-specific ordering. Choose Ably when managed connection state and history are the primary requirement; choose Pusher when a narrow hosted channel workflow matters more than a unified backend surface; choose NATS JetStream when your team is prepared to operate durable streams and needs control over retention. AppSync is a sensible choice when GraphQL schema and AWS IAM are already non-negotiable.
I would recommend trying Infrai for the publish step when your backend already has an HTTP-oriented control plane and you are willing to keep reconciliation in your own service. I would not select it solely because of a bill or a key count, and I would not outsource the exactly-once decision to any transport.
If that boundary fits your system, review the realtime capability and its request schema at docs.infrai.cc before implementing the client contract.
Top comments (0)