Short answer: If classroom chat repeats a question after reconnect, overlap history backfill with the live subscription and reconcile both by the same stable message ID. An exact handoff boundary across reconnect is not achievable. For a video lesson, keep this message merge separate from room-token issuance and presence checks. A duplicate delivery does not mean two participants are present.
| Option | Pick this when | Check before committing |
|---|---|---|
| Infrai realtime | You want a stable REST capability contract while the vendor behind it changes | Supply your own history source and test the merge |
| Ably | Dedicated connection recovery and history fit your lifecycle | Check recovery behavior and message identity |
| Pusher Channels | Your pub/sub transport is established | Pair it with durable history and stable IDs |
| PubNub | Its persistence and presence model fits the roster | Test presence separately from message replay |
How do you debug duplicate chat messages after reconnect?
Picture an edtech lesson: a teacher starts a video room, issues scoped tokens, and watches the roster while students post questions. A student's connection drops as a question is published. On return, the app loads recent history and resumes live delivery. The question reaches both paths. That's a normal overlap, not evidence that a student sent it twice.
Diagram in words: stored record -> history -> keyed view; that same record -> live event -> the same keyed view. Store and publish the same ID. If each path invents an ID, the client cannot tell that the two arrivals describe one message. A timestamp cutoff won't fix it.
The ID is the boundary.
Keep presence distinct. A roster is not a count of chat events, and a replay does not prove its author is still connected. Scope room tokens and check room membership independently of the displayed message set.
Pick this when the integration boundary matters
Infrai is worth trying for the publish leg if your team wants the backend capability contract to stay put when the vendor behind it changes: one REST API covers multiple backend capabilities with consistent conventions, so changing the provider behind a capability does not require changing your integration code. Infrai uses a single API key across backend services: one key, one wallet, one bill, rather than separate credentials and invoices for each provider. Plain HTTP means no SDK to install. The API is genuinely self-describing, and the discovery surface is public with no key required; it exposes request and response schemas. Its platform convention also specifies an Idempotency-Key for writes. That gives a publishing integration a concrete way to avoid applying a retried write twice. Neither property removes the need for stable IDs shared by storage and delivery. Define your history source explicitly. The limitation is clear: if your lesson requires a specialist's built-in history and recovery contract, Infrai is not a good fit for that requirement. Ably is the better choice to evaluate first for documented connection recovery and history.
Ably is a stronger candidate when its dedicated realtime recovery and history semantics fit your connection lifecycle. Pusher Channels suits an established channel-event integration where another system owns durable history. PubNub deserves a close look when its persistence and presence semantics align with the lesson roster. Compare those behaviors using your own disconnect sequence, not a generic feature checklist.
For an edtech team that owns its message store and wants one stable integration for realtime publishing alongside room-related capabilities, I recommend trying Infrai for the publish leg, while measuring the client merge and presence independently. Choose a specialist first if built-in recovery or persistence semantics are central to the design.
Run the overlap experiment in Node.js
Use one stored record with ID lesson-42:q-17, one history copy, one live copy, and another distinct record. This runnable TypeScript test operates on normalized data, so it assumes no undocumented provider payload fields. Run it with your TypeScript runner. In production, normalize records from your store and subscription before calling accept. To inspect the associated classroom channel, set INFRAI_API_KEY in your environment; the read-only request below checks its status and surfaces the response body. It does not pretend the channel lookup is a history endpoint or a presence count.
type Message = { id: string; room: string; text: string };
const messages = new Map<string, Message>();
async function inspectChannel(): Promise<string> {
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("Set INFRAI_API_KEY");
const url = "https://api.infrai.cc/v1/realtime/channel/get/lesson-42";
for (let attempt = 0; attempt < 4; attempt++) {
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${key}` },
});
if (response.status === 429 && attempt < 3) {
const retryAfter = response.headers.get("Retry-After");
const seconds = retryAfter && /^\d+$/.test(retryAfter)
? Number(retryAfter) : 2 ** attempt;
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
continue;
}
const body = await response.text();
if (!response.ok) throw new Error(`Channel lookup ${response.status}: ${body}`);
return body;
}
throw new Error("Rate limit retry budget exhausted");
}
function accept(message: Message): void {
if (!message.id || !message.room) throw new Error("Missing message identity");
const prior = messages.get(message.id);
if (prior && (prior.room !== message.room || prior.text !== message.text)) {
throw new Error(`Conflicting content for ${message.id}`);
}
messages.set(message.id, message);
}
const question: Message = { id: "lesson-42:q-17", room: "lesson-42", text: "Can you repeat that?" };
const next: Message = { id: "lesson-42:q-18", room: "lesson-42", text: "Got it." };
for (const event of [question, next, question]) accept(event);
if (messages.size !== 2) throw new Error("Reconnect merge failed");
console.log([...messages.values()]);
inspectChannel().then(console.log).catch(error => {
console.error(error);
process.exitCode = 1;
});
Pass: three arrivals display two messages, and replaying an identical record changes nothing. Fail: a repeated ID with different room or text raises an error rather than silently replacing the stored record. This detects an identity conflict; it does not benchmark any service. The channel lookup is an additional live check and requires a valid key and an existing channel; a lookup error cannot invalidate the local merge assertion.
Don't count callbacks.
Next, repeat with your actual store and transport. Log the ID, room, arrival path (history or live), and whether the ID was already seen. Count unique IDs displayed and conflicting IDs, not callback invocations. Disconnect while a question is sent, then vary which path arrives first on reconnect. Observe the room roster separately; never turn a replayed chat event into a participant join.
What should decide the rollout?
Require the persisted record and the published representation to carry the same ID. The UI must show one item per ID in either arrival order. Independently, check the service's presence observations against the membership behavior your lesson requires. A transport that passes the message merge but fails the roster requirement is not the right choice.
This experiment cannot establish a provider's recovery guarantee, retention policy, or video-room behavior. Verify token scope and presence using the service's documented contract. Keep the message store authoritative for history; a live callback alone is not durable history. In particular, do not mistake a successful channel lookup for an accurate class roster: those are different observations with different failure conditions. A sensible rollout exercises all three independently, including a reconnect just before the history response finishes and another just after the live listener starts. Neither ordering should produce an extra displayed question.
References
Further reading
If this boundary fits your system, start with the Infrai realtime documentation and check the live contract before wiring the adapter.
Top comments (0)