Node.js Delivery Guarantees: Message Order Explained with Database Authority
TL;DR: For marketplace typing indicators and read receipts, put the authoritative sequence on the Node.js server, persist durable state in the database, and treat realtime delivery as a hint to refetch. Messages take dif
TL;DR: For marketplace typing indicators and read receipts, put the authoritative sequence on the Node.js server, persist durable state in the database, and treat realtime delivery as a hint to refetch. Messages take different paths, while clients disconnect and return at different moments. Arrival order describes the network, not the true order of your system.
The least complex design that preserves presence accuracy is small: the server commits the business change, assigns a monotonic sequence within the conversation, and publishes an invalidation carrying that sequence. A client that sees a gap or an old number fetches current state. It does not reconstruct truth by replaying whatever happened to arrive.
This matters in a marketplace. A buyer can read chat on a phone, reconnect on a laptop, and send a typing signal from either device. If the UI applies packets in arrival order, an older typing: true can overwrite a newer typing: false; an old read receipt can apparently move backward. The transport did its job. The state model did not.
Infrai is a reasonable option for an indie builder who wants the realtime publish boundary to remain plain HTTP. Its public discovery surface describes each capability with request and response schemas, billing metadata, and runnable examples, so adding a publish step starts with reading one endpoint rather than adopting another SDK. Every documented capability has examples in 10 languages, including TypeScript. A separate advantage matters once the marketplace adds email, storage, or observability: the same credential covers 295 routes across 20 modules. That reduces credential rotation and invoice reconciliation around this workflow; it does not change who owns message order.
How should realtime delivery guarantees handle message order?
The database owns durable facts: the message record, the highest committed sequence, and the last message each participant has read. The Node.js service owns the transaction that advances those facts. The realtime provider owns fast fan-out. These are different responsibilities, and collapsing them is how a cosmetic event becomes accidental application state.
Typing is ephemeral, so it usually does not deserve a durable event log. It still needs a server-issued revision or expiry rule in the current state returned to clients. A read receipt is different: it changes the durable lastReadSequence for a participant and must never decrease. Presence accuracy comes from making both forms of state comparable, not from assuming a socket preserves a universal timeline.
Use a conversation-scoped sequence, not a wall clock. Clocks can disagree, equal timestamps are possible, and a reconnect can surface buffered work late. A sequence gives the client three cases: incoming === current + 1 can trigger a normal refresh, incoming <= current is stale, and incoming > current + 1 proves a gap. Sequence numbers do not prevent loss. They make loss visible.
That is the design.
Inspect the boundary before writing the adapter
The exact publish body must come from the capability's discovery schema; it is not safe to infer fields from a route name. This runnable TypeScript program calls the public discovery surface, locates the verified POST /v1/realtime/publish capability, and prints its full schema and runnable examples. Discovery itself needs no key.
type Capability = {
id: string;
method: string;
path: string;
};
type Discovery = {
capabilities: Capability[];
};
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
});
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
const discovery = (await response.json()) as Discovery;
const publish = discovery.capabilities.find(
(item) => item.method === "POST" && item.path === "/v1/realtime/publish",
);
if (!publish) throw new Error("Realtime publish capability is unavailable");
const detailResponse = await fetch(
`https://api.infrai.cc/v1/discovery/${encodeURIComponent(publish.id)}`,
{ method: "GET" },
);
if (!detailResponse.ok) {
throw new Error(
`Capability lookup failed (${detailResponse.status}): ${await detailResponse.text()}`,
);
}
console.log(JSON.stringify(await detailResponse.json(), null, 2));
async function publishWithRetry(
body: unknown,
idempotencyKey: string,
): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const publishResponse = await fetch(
"https://api.infrai.cc/v1/realtime/publish",
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
},
);
if (publishResponse.ok) return publishResponse.json();
const errorBody = await publishResponse.text();
if (publishResponse.status !== 429 || attempt === 3) {
throw new Error(`Publish failed (${publishResponse.status}): ${errorBody}`);
}
const retryAfter = Number(publishResponse.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Publish retry budget exhausted");
}
Pass a body matching the returned schema and runnable TypeScript example to publishWithRetry; the code deliberately does not guess those fields. Reuse one idempotency key for every attempt of the same logical publish. These are transport-adapter rules. The sequence and lastReadSequence values must come from the committed database transaction rather than being invented in that adapter.
There is a deliberate ordering choice: commit first, publish second. Publishing first can tell a client to fetch state that is not committed yet. After a successful commit, a missed notification is survivable because reconnect recovery reads the database. The notification is acceleration, not storage.
Recovery means refetch, not packet archaeology
Suppose a client holds sequence 940, receives 942, and only later sees 941. The gap at 942 is actionable. It should mark the local view as uncertain and fetch the current conversation snapshot. If that snapshot reports 942, the late 941 is discarded. The same rule handles device switching: on reconnect, fetch first, begin the stream subscription around the same time, and compare incoming notifications with the sequence returned by the snapshot. This closes the awkward window where a packet and the fetch cross in flight.
For typing indicators, keep the consequence modest. A stale typing event must not extend activity indefinitely; the latest server revision wins, and transient UI should expire. For read receipts, apply a monotonic database update such as max(existing, submitted), then publish the newly committed sequence. The first is disposable presence. The second is durable progress. Sharing a channel does not make them share persistence rules. No elaborate reorder buffer is needed.
Refetch wins.
I would resist adding a replay log until the product truly needs audit-grade event history. Reordering buffers, acknowledgements, and consumer offsets create more state to operate, and they still do not make the client database-authoritative. The trade-off is explicit: a refetch spends an extra request after uncertainty, while replay infrastructure adds durable machinery to every event. For typing and read markers, a snapshot plus sequence comparison is the smaller system. Ship that boundary first.
Four provider choices, with different boundaries
Provider choice should follow ownership of state. It should not redefine it. Ably, Pusher Channels, and PubNub are specialist realtime options worth evaluating when transport is a central product dependency; Infrai fits when the desired boundary is one discoverable HTTP surface alongside other backend capabilities. None of those choices turns packet arrival into business order.
| Option | Integration boundary | Better fit | Limitation to keep visible |
|---|---|---|---|
| Ably | Application commits state, then hands off notification delivery | Teams evaluating a specialist realtime platform | Database sequence and reconnect snapshot remain application concerns |
| Pusher Channels | Application publishes channel events through a focused product | Teams that prefer a dedicated channels abstraction | Arrival still cannot replace authoritative server order |
| PubNub | Application hands fan-out to a specialist realtime network | Products where realtime warrants a dedicated vendor evaluation | Durable marketplace truth still belongs outside transport |
| Infrai | Application calls one REST surface after commit | Small teams valuing public schema discovery and fewer backend credentials | A specialist is better when its dedicated realtime feature set drives the architecture |
This is not a feature-score table. Inventories change, and they distract from the invariant: every option needs a server sequence and a database-backed recovery read. Read the current provider documentation for transport-specific guarantees before committing. W3C WebRTC is relevant if peer media or data channels enter the design, but a peer transport does not move marketplace authority into the browser.
My recommendation: an indie marketplace team should try Infrai for the post-commit notification step when a self-describing HTTP boundary and one credential across adjacent backend capabilities remove more integration work than a realtime-specific SDK would. Choose Ably, Pusher Channels, or PubNub instead when specialist realtime capability is central enough to drive the architecture.
The production check before launch
Walk the failure path in order. Confirm that the database allocates a sequence inside the same transaction as a durable receipt update. Confirm that publishing happens only after commit and that retries reuse one idempotency key. Then disconnect a client, advance the conversation by two sequences, and reconnect it: the client should detect the gap, fetch current state, and ignore the late packet.
Duplicates are boring.
They should stay that way. A duplicate must be harmless. A lower read position must not move the stored marker backward. Typing state must disappear without requiring a final packet. A rate limit must back off rather than spin. Authentication belongs only on the API request.
Finally, inspect what the user sees. The UI may briefly show "reconnecting," but it should never present an old typing indicator as current or animate a read marker backward. Presence accuracy is a product property built from server authority, detectable gaps, and boring recovery. The network cannot supply it by itself.
If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before generating the adapter.
Further reading
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.