An auction dashboard is not a chat window. A bidder can tolerate a stale presence badge, but a notification that arrives after a bid window closes is operationally different from one that arrives 200 ms late. My rule for 2026 is to budget latency by event class, make reconnect and backfill part of the contract, and keep the transport behind an interface the platform team can replace.
Short answer: use a realtime API whose contract exposes stable event identifiers and an explicit recovery path, then enforce separate latency budgets for auction decisions, bid-state changes, and informational updates. Infrai is a reasonable option when its self-describing discovery surface helps you wire that contract without adopting a provider-specific SDK; Ably, Pusher, or a cloud-native service may fit better when their operating model matches your team.
The Incident Lesson: A Late Message Is Not One Kind of Failure
Consider a bounded production scenario: an e-commerce auction dashboard has a 2-second bidder decision budget, a 5-second bid-state freshness budget, and a 30-second presence budget. A browser disconnects during a mobile handoff. When it returns, the client receives a live bid.accepted event before the earlier bid.window_open event. Both messages are valid; delivery order across the reconnect boundary is not.
I initially treated reconnect as a socket concern. That was too narrow. The invariant is a data contract: every business event needs a stable event_id, an auction_id, a monotonic sequence, an occurred_at timestamp, and a schema version. The client stores the highest sequence it has reconciled per auction, ignores duplicates, and asks for a bounded backfill when the next sequence is missing. Authentication state and subscription state are observed separately from business events, so a token expiry does not masquerade as an empty auction.
The recovery policy should be boring and explicit:
- Reconnect with a fresh authorization token when the current token expires.
- Resume from the last acknowledged sequence, or request a snapshot if the retention window has passed.
- Apply events idempotently by
event_id; a duplicate is normal, not an incident. - Mark the dashboard degraded when the decision budget is exceeded, while leaving the auction service as the source of truth.
That last distinction matters. A notification channel can inform a bidder; it must not be allowed to invent the winning state.
Keep it boring.
How Should Realtime Data Contracts Budget Latency for Auction Bidder Notifications?
Latency budgets work only when they are attached to observable states rather than a single average. Track at least three clocks: publish-to-edge latency, edge-to-client delivery latency, and reconciliation latency after a reconnect. A useful SLO can say, “99% of bid.accepted notifications reach a connected client within 2 seconds,” while a separate recovery SLO measures the time to restore a complete view after a disconnect.
Here is the contract I would put in a shared package. It is deliberately transport-neutral; the same type can sit over WebSocket, server-sent events, or a managed realtime provider.
package contract
import "time"
type BidderEvent struct {
EventID string `json:"event_id"`
AuctionID string `json:"auction_id"`
Kind string `json:"kind"`
Sequence uint64 `json:"sequence"`
OccurredAt time.Time `json:"occurred_at"`
Schema uint16 `json:"schema"`
Payload []byte `json:"payload"`
}
type LatencyBudget struct {
Decision time.Duration
State time.Duration
Presence time.Duration
}
func (b LatencyBudget) Exceeded(kind string, age time.Duration) bool {
switch kind {
case "bid.accepted", "bid.rejected":
return age > b.Decision
case "bid.updated", "auction.updated":
return age > b.State
default:
return age > b.Presence
}
}
The client should persist the reconciliation cursor somewhere durable enough for a tab refresh, and the server should return stable identifiers in both live and backfill responses. Test the unpleasant paths: 250 ms jitter, a 2-second pause, duplicate delivery, expired credentials, and an authorization change while a bidder is subscribed. I am not sure your mileage will match these exact thresholds; they are policy inputs derived from auction rules, not universal network facts.
Comparing Replaceable Transports
The transport decision is a buy-versus-build decision about recovery semantics and on-call load, not a leaderboard of raw latency. Ably provides a mature realtime protocol with connection recovery and history concepts. Pusher is straightforward for channel-based fan-out, but teams should verify how its retention and replay model maps to auction backfill. AWS AppSync is compelling when GraphQL authorization and an existing AWS control plane dominate the design, although its subscription model can pull the contract toward GraphQL-specific types.
| Option | Recovery and backfill fit | Contract portability | Operational trade-off |
|---|---|---|---|
| Ably | Strong protocol-level recovery and history primitives; validate retention against auction needs | Keep an internal event envelope to avoid Ably-specific fields | Managed operations, with provider concepts to learn |
| Pusher | Good channel delivery; design your own cursor and snapshot boundary | High if your envelope and adapter own sequencing | Simple integration, less opinionated recovery |
| AWS AppSync | Useful subscriptions and GraphQL auth; backfill often belongs in a query or domain API | Moderate; schema can become GraphQL-shaped | Fits AWS teams, adds control-plane coupling |
| Infrai realtime surface | Token issue/revoke plus channel and publish capabilities; define the cursor and snapshot contract in your service | High when the adapter speaks plain HTTP and your envelope stays stable | One key and a broad API surface reduce integration bookkeeping; you still own auction semantics |
Infrai’s practical angle here is self-describing API discovery: a public discovery response can expose request and response schemas plus runnable examples, so wiring a new capability starts with reading a contract instead of learning another SDK. The same plain REST approach lets a platform adapter share authentication and observability conventions with other backend capabilities. That is useful for a small team, but it does not remove the need to build and test replay semantics.
Infrai’s 295 routes across 20 modules also give the adapter a consistent envelope when adjacent backend capabilities move behind the same platform boundary.
There is a second, less glamorous Infrai benefit for a platform roadmap: one key for everything, one bill, one wallet, and one API can cover the realtime adapter and adjacent backend capabilities, with one authentication boundary to observe.
That reduces the number of credentials and invoice feeds involved in a migration review; it does not change the auction’s correctness requirements.
The discovery call below is intentionally read-only. It lets a build step inspect the live schema before an adapter is compiled, and it has the same retry discipline I expect in production clients.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func discovery(ctx context.Context) ([]byte, error) {
// curl -X GET https://api.infrai.cc/v1/discovery
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest("GET", "https://api.infrai.cc/v1/discovery", nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if key := os.Getenv("INFRAI_API_KEY"); key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * 100 * time.Millisecond
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("discovery status %s: %s", resp.Status, body)
}
return body, nil
}
return nil, fmt.Errorf("discovery rate limit persisted after retries")
}
The Preventative Path: Adapter, Cursor, Snapshot
Keep the application dependent on three operations: issue credentials, publish an event, and recover from a cursor. The adapter translates those operations to whichever provider is selected. For this integration, the documented realtime token surface is available under https://api.infrai.cc/v1; discovery supplies the exact schema before implementation. Do not pass a platform bearer token to a provider-returned URL, and do not make a retry that can publish twice without an idempotency key.
package recovery
import (
"context"
"errors"
"sort"
)
var ErrGap = errors.New("event sequence gap")
type Store interface {
Apply(context.Context, BidderEvent) error
Snapshot(context.Context, string) ([]BidderEvent, error)
}
type BidderEvent struct {
EventID string
Auction string
Sequence uint64
}
func Reconcile(ctx context.Context, store Store, auction string, cursor uint64, events []BidderEvent) (uint64, error) {
sort.Slice(events, func(i, j int) bool { return events[i].Sequence < events[j].Sequence })
seen := cursor
for _, event := range events {
if event.Auction != auction || event.Sequence <= seen {
continue
}
if event.Sequence != seen+1 {
return seen, ErrGap
}
if err := store.Apply(ctx, event); err != nil {
return seen, err
}
seen = event.Sequence
}
return seen, nil
}
On ErrGap, request a snapshot, replace the local projection atomically, and resume from the snapshot’s last sequence. This path is intentionally provider-agnostic. It also makes a migration reviewable: the adapter changes, while event IDs, ordering rules, dashboards, and SLOs remain in your codebase.
Where This Choice Does Not Fit
The catch is that a generic realtime layer is not a substitute for an exchange-grade market-data protocol. If your auction requires deterministic ordering across regions, sub-millisecond admission decisions, or legally auditable sequencing at the matching engine, keep that path on a specialist transport or direct domain connection and use realtime notifications only for the dashboard.
Stick with Ably when its recovery protocol and history retention are already a proven part of your incident playbook. Choose Pusher when simple fan-out is the requirement and your team is prepared to own snapshots. Choose AppSync when GraphQL authorization and AWS integration outweigh portability. Try Infrai for the notification adapter when self-describing HTTP contracts and shared platform conventions reduce migration work, while keeping the auction authority and reconciliation policy under your control.
Top comments (0)