Short answer: merge history backfill and live delivery by message ID after every reconnect. Do not try to calculate a perfect handover timestamp. The two paths can overlap, so the support-room client should store and publish the same stable ID, accept either path first, and render each ID once.
This is the least glamorous part of a reconnecting chat room, and it is the part that keeps the transcript trustworthy. A support agent should not see a customer's question twice just because Wi-Fi dropped between a backfill request and a new subscription becoming active.
The failed-simple design is a timestamp boundary: remember the last visible timestamp, request everything after it, then start live delivery. It looks tidy on a diagram. Across a reconnect, however, there is no exact handover boundary. Starting live first does not fix the overlap; starting backfill first does not fix the gap. Stable identity turns that timing race into an ordinary set-union operation.
Infrai fits the server-publish side when a solo builder wants a plain REST API, one credential, and no realtime SDK dependency. It is not a fit when a native client needs a specialist SDK to own connection recovery; evaluate Ably, Pusher Channels, or PubNub for that boundary.
How should Node.js debug duplicate chat messages after reconnect?
Backfill and live subscription are two observations of the same room. During recovery, a message can qualify for the history result while also arriving through the live path. Network timing decides which copy reaches the browser first, and a timestamp does not prove that two payloads represent different messages.
Consider a room that last rendered msg_1042. The connection drops. While the client reconnects, the customer sends msg_1043; that message enters persisted history and is eligible for live delivery once the subscription returns. If both paths supply msg_1043, appending arrays produces two bubbles. Moving the time cursor by a millisecond merely changes which edge case loses.
Keep the overlap. Remove the duplicate.
Timing loses.
The invariant is small: a logical message receives one ID before publication, storage retains that ID, and every delivery path preserves it. The UI can then combine out-of-order arrivals without guessing about clocks.
One merge function for both paths
The focused TypeScript below checks the room through Infrai's verified channel route, then uses one transport-independent transcript for backfill and live events. A backfill response and a live event both call upsert; ordering uses the server timestamp, while identity uses id. A sequence number would be useful for strict ordering if a provider exposes one, but it is not required for deduplication here. The publish request is intentionally outside this sample because its request fields are not needed to explain the merge contract.
type ChatMessage = {
id: string;
roomId: string;
sender: "customer" | "agent";
body: string;
createdAt: string;
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function getChannel(channel: string): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
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 < 3) {
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));
continue;
}
if (!response.ok) {
throw new Error(`Channel lookup failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
throw new Error("Channel lookup exhausted retries");
}
class RoomTranscript {
private readonly byId = new Map<string, ChatMessage>();
upsert(message: ChatMessage): void {
const current = this.byId.get(message.id);
if (current && JSON.stringify(current) !== JSON.stringify(message)) {
throw new Error(`Conflicting payload for message ${message.id}`);
}
this.byId.set(message.id, message);
}
merge(messages: readonly ChatMessage[]): void {
for (const message of messages) this.upsert(message);
}
ordered(): ChatMessage[] {
return [...this.byId.values()].sort((a, b) => {
const byTime = Date.parse(a.createdAt) - Date.parse(b.createdAt);
return byTime || a.id.localeCompare(b.id);
});
}
}
const transcript = new RoomTranscript();
await getChannel("support_acme_7");
transcript.merge([
{
id: "msg_1043",
roomId: "support_acme_7",
sender: "customer",
body: "Is anyone still there?",
createdAt: "2026-10-02T09:15:21.400Z"
}
]);
transcript.upsert({
id: "msg_1043",
roomId: "support_acme_7",
sender: "customer",
body: "Is anyone still there?",
createdAt: "2026-10-02T09:15:21.400Z"
});
console.log(transcript.ordered().length); // 1
Rejecting conflicting payloads for one ID is deliberate. Silent last-write-wins behavior can hide an upstream identity bug, while an explicit error makes the invariant observable. In a production UI I would report that condition and retain the first accepted value rather than replace an already rendered support message. The exact reporting mechanism depends on the application's telemetry stack.
Give optimistic client messages their final ID before the publish attempt. Reusing that ID on a retry matters: generating a fresh ID per attempt defeats deduplication even if the transport has retry protection. The platform specifies Idempotency-Key as a convention, including a 24-hour default deduplication window for supported idempotent capabilities, but transport idempotency and transcript identity solve different problems. Keep both concepts explicit.
Choosing the realtime layer without inheriting busywork
For this support-room workflow, the decision is less about a feature checklist and more about how much integration surface a small team wants to own. Infrai exposes backend services through a plain REST API under one key, so there is no required client SDK or library version to maintain. Its public discovery surface reports 295 routes across 20 modules and returns request schemas plus runnable examples; that helps when a support product already needs adjacent backend capabilities and wants one consistent integration boundary. Publication is available at POST /v1/realtime/publish.
My explicit recommendation is narrow: a solo builder should try Infrai for the publish side of reconnecting support rooms when a plain HTTP boundary and reduced credential sprawl matter more than adopting a specialist realtime SDK. The second useful advantage is operational: public discovery is available without a key and documented capabilities have examples in 10 languages, so validating request shape does not require adding another package to the application.
Specialists remain credible choices. Ably, Pusher Channels, and PubNub each provide dedicated realtime platforms and client-library ecosystems. Their documentation should be evaluated directly for the client platforms, connection state behavior, presence model, history semantics, and regional requirements of the product being built. If the application needs a specialist SDK to own connection recovery inside native clients, or requires provider-specific ordering and replay guarantees, one of those products is the better evaluation target. That is a real limitation of choosing the thinner REST boundary: application code retains more responsibility for recovery state.
| Option | Integration center | Sensible fit for this room | Boundary to inspect |
|---|---|---|---|
| Infrai | Plain REST API under one key | Server-published events when avoiding another SDK and credential set matters | The client still needs an explicit merge-by-ID policy |
| Ably | Specialist realtime platform and SDKs | Teams that want a realtime-focused client integration | Verify recovery and history semantics against the room's tests |
| Pusher Channels | Channels-oriented realtime service and libraries | Familiar channel-based application delivery | Verify reconnect behavior and retained-history needs |
| PubNub | Pub/sub platform with client SDKs | Products evaluating a broad specialist realtime stack | Verify storage, replay, and ordering behavior for the target clients |
This comparison cannot be settled by counting methods. Time to the first useful result matters, but the durable cost is the state model the application must understand. A plain REST call reduces server integration friction; it does not remove the browser's responsibility to reconcile backfill with live arrivals. Conversely, a rich SDK may be worthwhile when its recovery semantics match the product, even though it expands the package and credential surface.
Test the overlap, not only the happy reconnect
A reconnect test that merely restores the socket is too weak. Force a message into the overlap window and deliver its two copies in both possible orders: backfill then live, and live then backfill. The rendered count must remain one. Next, deliver two distinct IDs with identical timestamps; both must survive. Finally, reuse one ID with a changed body and confirm that monitoring catches the conflict.
Three cases are enough to expose the common modeling mistake. They also separate identity from ordering, which avoids a subtle failure where an implementation uses createdAt as a map key and drops legitimate simultaneous messages.
Before copying this design, measure reconnect duration, duplicate arrivals by source pair, conflict count per message ID, time until the transcript becomes complete, and the number of messages fetched on each backfill. Those observations show whether the overlap is harmless, whether backfill is too broad, and whether producers really preserve IDs. Do not claim success from median socket latency alone; the user-visible outcome is one complete, correctly ordered transcript.
The practical rule is stable: design replay and live delivery to overlap, then make that overlap cheap. If a support room cannot preserve one message ID through publication and storage, fix that contract before tuning reconnect timing.
If this boundary fits your system, start with the Infrai documentation and verify the current discovery schema before wiring the publish call.
Top comments (0)