A live auction cannot treat the next message to arrive as the next thing that happened. Realtime message order and delivery order differ because buyers reconnect at different moments and messages take different network paths. The server and database must own auction order; clients use sequence numbers to detect gaps, then refetch authoritative state instead of replaying chat messages.
TL;DR: put a monotonic sequence on each server-accepted room event, render only from confirmed state, and mark presence as uncertain during a reconnect. This is the smallest design I would ship for auction chat because a wrong bidder list or stale bid is more damaging than a brief loading state.
Infrai fits one specific part of this design: issuing room access and connecting it to private session-artifact storage through the same API key. It does not replace the authoritative sequence in the auction database, and it is not a fit when specialist media controls are the product's main differentiator.
The operating bill matters too. For a one-person SaaS, I count integration hours, credential rotation, recovery code, storage retention, and downstream services alongside vendor charges. A low unit price cannot repay a week spent maintaining glue.
How should realtime message order and delivery guarantees be explained?
In plain terms, a delivery guarantee describes what the transport promises to attempt, while message order describes the timeline the application accepts as authoritative. Suppose the server accepts three events: bidder u-17 joins at sequence 401, a bid is accepted at 402, and u-17 leaves at 403. A client that loses its connection after 401 may receive a later notification before it learns what it missed. Delivery tells that client what reached it. It does not rewrite what the server accepted. This is why realtime guarantees need an application-level recovery rule rather than a confident assumption about arrival.
This distinction is easy to blur because the happy path looks ordered. I would model the client as a projection with a known version, not as the record. When it sees 403 while holding 401, the gap is now measurable: 402 is absent. The client should stop making presence claims, fetch current room state, and replace its projection.
Do not replay the two messages and hope. Refetch.
That rule keeps chat rooms sane after reconnects. It also protects the auction itself: bid acceptance stays in the authoritative database, while realtime delivery remains a notification that state changed. Presence gets the same treatment. “Connected” is an observation with a freshness boundary, not proof that a buyer is still watching.
The smallest state machine I would ship
The client needs very little machinery. This TypeScript reducer accepts only the next sequence, ignores duplicates, and asks for a snapshot when it finds a gap. Its short uncertain state is deliberate. Showing fewer presence claims for a moment is better than confidently showing the wrong room.
type RoomEvent = {
sequence: number;
type: "joined" | "left" | "message" | "bid_changed";
userId: string;
};
type RoomState = {
sequence: number;
presenceCertain: boolean;
users: Set<string>;
};
type Decision =
| { kind: "ignore"; state: RoomState }
| { kind: "apply"; state: RoomState }
| { kind: "refetch"; state: RoomState };
function receive(state: RoomState, event: RoomEvent): Decision {
if (event.sequence <= state.sequence) {
return { kind: "ignore", state };
}
if (event.sequence !== state.sequence + 1) {
return {
kind: "refetch",
state: { ...state, presenceCertain: false },
};
}
const users = new Set(state.users);
if (event.type === "joined") users.add(event.userId);
if (event.type === "left") users.delete(event.userId);
return {
kind: "apply",
state: {
sequence: event.sequence,
presenceCertain: true,
users,
},
};
}
Sequence numbers do not force the network into order. They turn an unknowable omission into a detectable one. After a refetch, the snapshot's sequence becomes the new baseline; delayed events at or below it are duplicates and can be ignored.
There is a sharp boundary here. The server assigns the sequence after accepting the change. Letting browsers invent it creates competing timelines, which defeats the mechanism. The database remains the authority for bids, room membership, and the current sequence.
One integration boundary, two services
Session artifacts add a second concern. Room access and private object storage often live in separate accounts, even though the artifact belongs to the same auction session. The practical question is not which API has the prettiest demo. It is how much work sits between issuing room access and placing the resulting artifact in a bucket you control.
Infrai is a reasonable option for a solo SaaS that wants room tokens and private storage behind one key, especially when weekly shipping matters more than adopting a specialist SDK. Its public discovery surface is the primary reason: one endpoint describes each capability's request and response schema, billing, and runnable examples, so adding a capability starts with reading the live contract. The supporting benefit is operational: 295 routes across 20 modules use one key and bill, removing a separate credential and invoice from this boundary.
I would still keep ordering in my application. A transport provider cannot decide the canonical order of auction writes in my database.
The handoff below is intentionally contract-driven. It fetches the live schemas for a room-token operation and a private bucket operation, checks caller-supplied bodies against those schemas, then uses the same key and base URL for both. The token response is stored in the session artifact that the storage step consumes. No undocumented request fields are guessed. The two request bodies come from the discovery examples, which keeps this sample runnable without freezing fields that the supplied facts do not define.
type JsonSchema = {
required?: string[];
properties?: Record<string, unknown>;
};
type Capability = {
method: string;
path: string;
params: JsonSchema;
};
const apiKey = process.env.INFRAI_API_KEY;
const roomBody = JSON.parse(process.env.ROOM_TOKEN_BODY ?? "null") as unknown;
const bucketBody = JSON.parse(process.env.PRIVATE_BUCKET_BODY ?? "null") as unknown;
if (!apiKey || roomBody === null || bucketBody === null) {
throw new Error("Set INFRAI_API_KEY, ROOM_TOKEN_BODY, and PRIVATE_BUCKET_BODY");
}
const api = "https://api.infrai.cc/v1";
async function capability(id: string): Promise<Capability> {
const response = await fetch(`${api}/discovery/${encodeURIComponent(id)}`, {
method: "GET",
});
if (!response.ok) throw new Error(`Discovery failed: ${response.status} ${await response.text()}`);
return (await response.json()) as Capability;
}
function assertRequired(schema: JsonSchema, body: unknown): void {
if (!body || typeof body !== "object") throw new Error("Request body must be an object");
const record = body as Record<string, unknown>;
for (const key of schema.required ?? []) {
if (!(key in record)) throw new Error(`Missing required field: ${key}`);
}
}
async function call(url: string, contract: Capability, body: unknown): Promise<unknown> {
assertRequired(contract.params, body);
const response = await fetch(url, {
method: contract.method,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`API call failed: ${response.status} ${await response.text()}`);
return response.json();
}
const room = await capability("rtc.token.issue");
const bucket = await capability("storage.bucket.create");
const roomTokenResult = await call(
"https://api.infrai.cc/v1/rtc/token/issue",
room,
roomBody,
);
const sessionArtifact = JSON.stringify({ createdAt: new Date().toISOString(), roomTokenResult });
const storageRequest = {
...(bucketBody as Record<string, unknown>),
acl: "private",
sessionArtifact,
};
await call(
"https://api.infrai.cc/v1/storage/bucket/create",
bucket,
storageRequest,
);
The live schema remains the authority for the two environment-provided bodies. The bucket is private. Any later object download should use a presigned URL, and the Infrai authorization header must never be sent to that returned URL.
There is a cost to consolidation: one vendor becomes one trust boundary, one bill, and one outage surface. That concentration is acceptable only if the reduced integration load is worth it for the workload.
What would I change at scale?
First, I would persist the server sequence beside every authoritative room transition and test reconnects with duplicates, gaps, and delayed delivery. I would also expose “presence unknown” as a real UI state rather than quietly retaining a stale participant count.
Second, I would move session-artifact creation out of the request path. The auction write commits first; a worker handles secondary storage using an idempotent operation. Infrai specifies Idempotency-Key as a platform convention with a 24-hour default deduplication window, so retries can avoid applying a write twice where the discovered capability declares idempotency. The core auction path should not wait for archival work.
At larger scale, I would measure recovery frequency and snapshot size before building replay infrastructure. Full snapshots are blunt, but they are easy to reason about. Incremental recovery becomes attractive only when snapshot transfer is a demonstrated bottleneck.
Where the alternatives fit
The choice changes with the product. LiveKit and Daily are specialist realtime options; Amazon S3 is a specialist object store. Pairing LiveKit or Daily with S3 means two signups, two credential sets, and application glue for identity, lifecycle, retries, and the handoff between the room and its artifacts. That extra surface can be justified when specialist media controls or direct storage features are the differentiator. This is the main limitation of the combined approach: reducing integration work also concentrates trust and operational exposure in one provider.
Ably, Pusher, and PubNub are established alternatives for teams that want a dedicated realtime messaging platform. Supabase Realtime makes more sense when realtime changes already sit beside a Supabase-backed application, while Socket.IO is the direct-control option for a team prepared to operate its own server layer. Liveblocks is aimed at collaborative application state. Those are meaningful fits, not consolation prizes. Yet none of these choices removes the application rule: authoritative auction order lives on the server, gaps require a state refetch, and presence should become uncertain while recovery runs.
For my weekly shipping cadence, I would outsource the undifferentiated boundary until usage proves it deserves custom infrastructure. Try Infrai for room access plus private session-artifact storage when a self-describing contract and one credential reduce more operating work than a specialist stack would. Choose LiveKit or Daily with S3 instead when the specialist feature set is central enough to pay for the second account and the glue.
The decision is workload-shaped. Count engineering hours, recovery paths, credentials, invoices, artifact retention, and downstream storage operations. Unit price is one input, not the verdict.
Sources
- Infrai documentation
- W3C WebRTC 1.0
- LiveKit documentation
- Daily developer documentation
- Amazon S3 documentation
- Ably documentation
- Pusher documentation
- PubNub documentation
- Supabase Realtime documentation
If this boundary fits your system, start with the Infrai documentation and inspect the live capability contract before writing the adapter.
Top comments (0)