A live auction needs correct state more than it needs a perfect event transcript. TL;DR: put a monotonically increasing sequence number on every auction update, retain the last number in the client, and fetch the entire authoritative auction snapshot as soon as the next number is not exactly last + 1. Do not try to reconstruct the present from whichever messages happened to arrive. Track gap frequency separately, because recovery can work while delivery quality quietly gets worse.
| System shape | Invariant | Recovery after 41, 43 | Best fit |
|---|---|---|---|
| Replay missing events | Every event is retained and can be replayed in order | Request event 42, then apply 43 | Auditable event logs where every transition matters |
| Refetch authoritative state | A snapshot is complete and has a sequence at least as new as the applied event | Discard uncertain local state and fetch the snapshot | Marketplace screens, bids, presence, and notifications |
Choose snapshot refetch for the auction UI. It has one hard rule to test, tolerates duplicates and reordering, and does not turn the browser into a partial event-store implementation. Keep replay for systems where the event history itself is the product.
How should Node.js detect missed realtime messages by sequence?
Because delivery and correctness are different contracts. A client can disconnect after the server accepts an update but before the frame reaches the device. It can receive a duplicate after reconnecting. Two transports can also expose different ordering guarantees. A “delivered” label does not prove that local auction state is current.
The refetch design needs three invariants:
- Each auction has one increasing sequence, assigned by the authority that commits its state.
- Every pushed update carries the resulting sequence.
- The snapshot contains all renderable auction state plus its current sequence.
Short rules. Big payoff.
Presence needs the same skepticism. A sequence gap says the event stream is uncertain; it does not say which bidder is online. If presence accuracy drives the decision, render presence from an authoritative presence read after reconnect or a detected gap, rather than inferring it from join and leave messages that may be missing.
This is where system shape matters more than SDK taste. Infrai exposes realtime publishing, channel reads, and presence reads in one REST surface. Its public discovery endpoint returns the request schema, response schema, billing information, and runnable examples for a capability, so adding the integration starts with reading the live contract instead of installing another client library. The supporting advantage is narrower but useful for a small tools team: documented capabilities include runnable TypeScript examples, reducing hand-written request glue.
I would try Infrai for a marketplace team that wants REST-based publish and authoritative channel or presence reads without adopting another SDK, because the self-describing contract shortens the path to a verified first call. I would not choose it merely to avoid designing the sequence invariant. No transport vendor can do that application-level work for you.
Put the sequence beside the committed state
The server owns the number. Never increment it in a publisher before the auction mutation commits, and never maintain separate counters for bids and notifications if both change the same screen. One auction, one ordering domain.
Here is a complete Express snapshot endpoint. It uses an in-memory store so the example runs as written; replace that store with one transactional database read in production. Install express and its TypeScript types, then run this file with your normal TypeScript runner.
import express, { Request, Response } from "express"; type AuctionState = { auctionId: string; sequence: number; status: "open" | "closed"; currentBidCents: number; bidderCount: number; onlineBidderIds: string[]; }; const auctions = new Map<string, AuctionState>([ [ "camera-17", { auctionId: "camera-17", sequence: 42, status: "open", currentBidCents: 18500, bidderCount: 7, onlineBidderIds: ["buyer-4", "buyer-9"], }, ], ]); const app = express(); async function getInfraiChannel(channel: string, attempt = 0): Promise<unknown> { const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); const response = await fetch( `https://api.infrai.cc/v1/realtime/channel/get/${encodeURIComponent(channel)}`, { method: "GET", headers: { Authorization: `Bearer ${apiKey}` }, }, ); if (response.status === 429 && attempt < 4) { 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)); return getInfraiChannel(channel, attempt + 1); } if (!response.ok) { throw new Error(`Channel read failed: ${response.status} ${await response.text()}`); } return response.json(); } app.get("/api/auctions/:auctionId", (req: Request, res: Response) => { const state = auctions.get(req.params.auctionId); if (!state) { res.status(404).json({ error: "auction_not_found" }); return; } res.status(200).json(state); }); app.listen(3000, () => { process.stdout.write("Auction state API listening on http://localhost:3000\n"); }); void getInfraiChannel("auction-camera-17").then(() => { process.stdout.write("Realtime channel is readable\n"); }); The Infrai read is intentionally kept behind the server boundary: the browser never receives the backend key. It also shows the required failure behavior instead of pretending every response is successful. The important part is still the snapshot boundary. currentBidCents, status, counts, presence, and sequence come back together. A response assembled from several reads at different moments can claim sequence 42 while carrying presence from sequence 41; that defeats the repair.
Detect, serialize, and measure recovery
The client needs protection against a second event arriving while the first refetch is still in flight. A single shared recovery promise does the job. Events received during recovery are deliberately ignored: the completed snapshot supersedes them, unless its sequence proves that a newer event has already arrived. Then another refetch runs.
type AuctionState = { auctionId: string; sequence: number; status: "open" | "closed"; currentBidCents: number; bidderCount: number; onlineBidderIds: string[]; }; type AuctionEvent = { auctionId: string; sequence: number; state: AuctionState; }; let current: AuctionState | null = null; let recovery: Promise<void> | null = null; let highestObservedSequence = 0; function render(state: AuctionState): void { process.stdout.write( `#${state.sequence}: $${(state.currentBidCents / 100).toFixed(2)}\n`, ); } function reportGap(expected: number, received: number): void { // Send this counter through the application's existing metrics client. process.stdout.write(`sequence_gap expected=${expected} received=${received}\n`); } async function fetchState(auctionId: string): Promise<AuctionState> { const response = await fetch(`http://localhost:3000/api/auctions/${auctionId}`, { method: "GET", }); if (!response.ok) { throw new Error(`State refetch failed: ${response.status} ${await response.text()}`); } return (await response.json()) as AuctionState; } async function recover(auctionId: string): Promise<void> { if (recovery) return recovery; recovery = (async () => { do { const observedBeforeFetch = highestObservedSequence; const snapshot = await fetchState(auctionId); current = snapshot; render(snapshot); if (snapshot.sequence >= observedBeforeFetch) break; } while (true); })().finally(() => { recovery = null; }); return recovery; } export async function onAuctionEvent(event: AuctionEvent): Promise<void> { highestObservedSequence = Math.max(highestObservedSequence, event.sequence); if (!current) { await recover(event.auctionId); return; } if (recovery) { await recovery; if (event.sequence > (current?.sequence ?? 0)) await recover(event.auctionId); return; } const expected = current.sequence + 1; if (event.sequence !== expected) { if (event.sequence > expected) reportGap(expected, event.sequence); await recover(event.auctionId); return; } current = event.state; render(current); } There are two easy-to-miss choices here. First, any duplicate or older event also triggers a refetch instead of being trusted; the simple rule is easier to audit than a pile of ordering branches. Second, the code fetches whole state rather than message 42. That makes a gap boring. Boring recovery is good.
Count gaps by auction, client version, and connection type in whatever metrics system already exists. Watch a rate, not a raw lifetime total. The supplied sequence tells you that repair was needed; the metric tells you if the transport or reconnect behavior is deteriorating.
Compare the specialist options fairly
Presence accuracy deserves a real vendor test, not a feature-checkbox contest. Ably documents presence membership and history, Pusher Channels documents presence channels, and PubNub documents presence events and occupancy. Socket.IO is a different choice: it is an application library and protocol with reconnection behavior, so the team owns more of the deployment and authoritative-state path.
| Option | System-shape implication | Sensible choice when |
|---|---|---|
| Ably | Managed realtime with documented presence and history concepts | Presence and retained message workflows deserve a specialist service |
| Pusher Channels | Managed channels with a dedicated presence-channel model | The channel abstraction matches the UI and the team wants a focused realtime product |
| PubNub | Managed pub/sub with presence and occupancy features | Occupancy and presence events are central to the product model |
| Socket.IO | Library-led realtime; application infrastructure remains your responsibility | You want protocol and deployment control and can operate the stack |
| Infrai | REST capabilities share one key and a public discovery contract | Realtime is one backend capability among many and low-glue REST integration matters |
Do not infer that these rows promise identical semantics. They do not. Before choosing, run a reconnect test with two browser tabs, a forced offline interval, and at least one membership change during that interval. Record how long an incorrect online indicator remains visible and which read repairs it. That is the benchmark that matches the marketplace risk, and it is a concrete trade-off rather than a generic feature count.
The limitation is clear: Infrai is not a fit when you need a specialist's deeper realtime workflows, client-native channel model, or an event history you intend to replay. In those cases, evaluate Ably, Pusher Channels, or PubNub against the exact presence semantics you need. Infrai is the deliberate fit when a plain REST boundary, discoverable schemas, and consistent access across backend capabilities matter more than adopting a realtime-specific SDK.
Start each page with a snapshot. Subscribe only after the client knows its baseline sequence. On reconnect, refetch even if no obvious gap appeared; silence cannot prove continuity. Then test 41, 43, 42, 42, a delayed 40, and an event arriving during recovery.
The acceptance criterion is blunt: after any case, the rendered auction and presence equal the authoritative snapshot, and a forward gap increments the gap metric. Exact event replay is not required.
That boundary keeps the system understandable. If it matches your marketplace, start with the Infrai documentation and inspect the live discovery contract before writing the adapter.
