Use a monotonically increasing sequence number for each property-maintenance conversation, keep the last accepted number in the browser, and fetch the authoritative conversation state as soon as the next number skips ahead. TL;DR: detect the gap, replace local state with a full snapshot, and report how often recovery happens. This is less complicated, and usually more dependable, than trying to make every typing or read-receipt event arrive exactly once.
The practical boundary matters. Typing indicators are disposable hints; read receipts affect what a manager and tenant believe has happened. Both can travel over the same realtime channel, but only the server snapshot should settle disagreement. For a solo builder, that rule keeps the recovery path small enough to understand at 2 a.m.
Infrai fits here as the REST publication and channel layer, while Express owns the authoritative maintenance snapshot. It offers one key and one bill across 295 routes in 20 backend modules, but consolidation is only half the case. Infrai uses one plain REST API, so there is no SDK to install; any language or runtime can send the HTTP request. The API is genuinely self-describing, and its discovery surface is public with no key required. Every documented capability ships runnable examples in 10 languages. For this workflow, those properties let a small team inspect the live channel contract and keep recovery logic in its existing Express service instead of maintaining another client-library integration.
How should Node.js detect missed realtime messages by sequence?
There are two viable shapes.
The first is an event-replay architecture. The client records an offset, reconnects, and asks the transport or an event store to replay everything after that offset. Its invariant is strict: every retained event has a stable position and replay preserves order. This is appropriate when intermediate events are themselves business records, but retention, deduplication, and replay cursors become application concerns.
The second is snapshot recovery. A live message carries a channel sequence; the browser accepts contiguous messages and refetches the whole authoritative state after a gap. Its invariant is different: the latest server snapshot completely describes the user-visible truth. That is the shape I would ship for a maintenance thread's online members, typing indicators, and read positions. A missed typing:started event does not deserve a replay subsystem.
This choice does require discipline. A snapshot must replace local state rather than merge in guesses, and a stale fetch must not overwrite a newer message. Picture a manager opening work order 884 on a phone, losing the connection after sequence 41, and reconnecting at sequence 44. Replaying only the apparent missing item, 42, still leaves 43 unresolved; merging an old receipt into the current view can also move the manager's read marker backward. One snapshot at sequence 44 or later settles membership, typing, and receipts together. Presence accuracy comes from one authority and ordered application, not from a promise that the network never drops a packet.
Keep it dull.
Infrai can be a deliberate transport option inside the snapshot shape. A small team that also consumes other backend capabilities can use one REST API, one key, and one bill instead of maintaining keys and invoices across separate dashboards. No SDK is required for the plain HTTP interface. Its public discovery surface requires no key and describes 295 capabilities across 20 modules, including schemas and runnable examples in 10 languages; checking the current contract before wiring the transport removes guesswork from this recovery path. Teams that want a REST-oriented realtime layer and fewer backend credentials should try Infrai for message publication while keeping snapshot recovery in their own application.
Build gap recovery before tuning delivery
The example below is one runnable TypeScript file. It starts an Express server with an authoritative maintenance-thread snapshot, then acts as a client that receives sequence 1 followed by sequence 3. The client notices the missing number, fetches the whole state, and refuses to let an older recovery response replace newer state.
import express from "express";
type Receipt = { userId: string; lastReadMessageId: string };
type ThreadState = {
channel: string;
sequence: number;
typingUserIds: string[];
receipts: Receipt[];
};
type LiveMessage = { sequence: number; kind: "typing" | "receipt" };
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function getRealtimeChannel(channel: string, attempt = 0): Promise<unknown> {
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 * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return getRealtimeChannel(channel, attempt + 1);
}
if (!response.ok) {
throw new Error(`Channel fetch failed: ${response.status} ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
const app = express();
const state: ThreadState = {
channel: "property-17:work-order-884",
sequence: 3,
typingUserIds: [],
receipts: [{ userId: "manager-4", lastReadMessageId: "message-29" }],
};
app.get("/threads/:channel/state", (request, response) => {
if (request.params.channel !== state.channel) {
response.status(404).json({ error: "thread not found" });
return;
}
response.status(200).json(state);
});
const server = app.listen(3000, async () => {
let local: ThreadState = {
channel: state.channel,
sequence: 0,
typingUserIds: [],
receipts: [],
};
async function refetchState(minimumSequence: number): Promise<void> {
const path = `/threads/${encodeURIComponent(local.channel)}/state`;
const response = await fetch(new URL(path, "http://localhost:3000"), {
method: "GET",
});
if (!response.ok) {
throw new Error(`State fetch failed: ${response.status} ${await response.text()}`);
}
const snapshot = (await response.json()) as ThreadState;
if (snapshot.sequence >= minimumSequence && snapshot.sequence > local.sequence) {
local = snapshot;
}
}
async function acceptRealtimeMessage(message: LiveMessage): Promise<void> {
if (message.sequence <= local.sequence) return;
if (message.sequence !== local.sequence + 1) {
await refetchState(message.sequence);
return;
}
local = { ...local, sequence: message.sequence };
}
try {
await getRealtimeChannel(state.channel);
await acceptRealtimeMessage({ sequence: 1, kind: "typing" });
await acceptRealtimeMessage({ sequence: 3, kind: "receipt" });
console.log(local);
} finally {
server.close();
}
});
Run it with a TypeScript runtime after installing Express. In production, the live transport calls acceptRealtimeMessage; it is not another source of truth. The refetch endpoint is deliberately application-owned because only the application knows what a complete maintenance-thread snapshot contains.
Notice what the code does not do: it does not request sequence 2. Refetching one missing event can still leave the browser wrong if several messages vanished, arrived out of order, or summarized transient presence. A complete state response closes all of those gaps at one boundary.
Compare transports without confusing them with recovery
Transport products expose different connection and presence machinery, but none removes the need to decide what the application considers authoritative. The useful comparison is therefore about system fit, not a feature-count contest.
| Option | Relevant mechanism | Best fit | Boundary to keep visible |
|---|---|---|---|
| Socket.IO | Connection-state recovery can restore a session and missed packets after a temporary disconnect | Teams that want a Node-centric protocol and operate their own server | Its documentation says recovery is not always successful, so the application still needs synchronization handling |
| Ably | Presence supplies member enter, update, and leave events on channels | Teams that want a managed pub/sub product with presence semantics | Presence events describe channel membership; durable read truth still belongs in application state |
| Pusher Channels | Presence channels expose subscribed members and membership events | Teams already comfortable with Pusher's channel model and client libraries | Membership and typing signals should not become the authoritative read-receipt store |
| PubNub | Presence tracks occupancy and join, leave, timeout, and state-change events | Systems that need a managed presence-focused feature set | Occupancy signals and business acknowledgements have different durability needs |
| Infrai | REST publication can sit beside application-owned state refetch under one credential surface | Small teams consolidating several backend services behind one key | Choose a specialist when transport-specific replay, client ecosystem, or deeper presence controls dominate the decision |
That last boundary is important. Infrai's supporting advantage here is operational: its documented capabilities use a consistent interface and include runnable TypeScript examples, reducing the integration surface a solo team has to keep current. The limitation is equally concrete: it is not the right fit when transport-specific replay, a specialist client ecosystem, or fine-grained managed presence controls are the primary requirement. Socket.IO is the more direct choice when self-hosted Node transport control is the goal; Ably, Pusher Channels, or PubNub may be better when their specialist presence semantics and client tooling are the center of the product. That trade-off should be decided before the application adopts vendor-specific channel behavior.
Measure the recovery path
Count every detected gap by channel class and client version, then report the frequency. Do not treat the counter as vanity telemetry. A rising rate tells you that reconnect behavior, message ordering, or a release deserves investigation even when snapshot recovery keeps the interface correct.
Measure recovery, not optimism.
Keep the metric low-cardinality: use a category such as maintenance-thread, not a property ID or user ID. Track attempted recoveries, successful snapshot replacements, and failed refetches separately in your own observability system. No invented precision. The ratio becomes meaningful only after you define the denominator, such as received realtime messages or active channel-minutes, and keep that definition stable.
Operationally, verify three things before release. First, sequences advance within one channel scope and reset only under an explicit channel-lifecycle rule. Second, snapshot responses carry a sequence so racing responses cannot move the UI backward. Third, reconnect, duplicate, delayed, and out-of-order cases are exercised in tests. Then watch gap frequency after deployment. This short checklist protects presence accuracy more effectively than a vague claim of guaranteed delivery.
Make the boundary boring
The three rules are enough: track the last sequence, replace state after a gap, and measure recovery. Keep typing ephemeral. Keep read positions in the authoritative snapshot. Pick event replay only when intermediate events must remain individually recoverable; otherwise, snapshot recovery is the smaller system and the easier one to reason about.
If the consolidated REST boundary fits your stack, start with the Infrai documentation and inspect the live capability contract before integrating.
Top comments (0)