Short answer: A health chat room cannot make the reconnect handover exact: history backfill and the live subscription will sometimes cover the same message, so deduplicate by message ID on the client and store and publish that same ID.
Treat presence separately; an apparently accurate online roster does not prove that the message stream has no overlap.
That is the operational constraint. It changes the design from “find a perfect cursor” to “make replay harmless.” A reconnect may race a publish, a history read may include the boundary event, and a live subscriber may already have received it. The correct result is still one rendered message.
How should you debug duplicate chat messages after reconnect?
The dangerous timeline is short. A clinician's browser loses transport, message msg-1042 is accepted while it is offline, and the browser reconnects. It starts a live subscription and requests missed history. If both paths contain msg-1042, two callbacks arrive even though only one message was sent. Log the ID and source at the final merge point; matching IDs from live and backfill identify the overlap without relying on payload guesses.
I have been paged for duplicate deliveries in production queue infrastructure. I initially assumed tightening the handoff checkpoint would settle it; later, I found that it reduced one race and exposed another because different systems observed the checkpoint at different times. The durable lesson was narrower and more useful. Delivery boundaries are coordination boundaries, so consumers need an idempotency key.
Presence accuracy makes the trap harder to see in healthtech. A room can correctly show that a nurse has rejoined while its message recovery is still running. Conversely, a temporarily stale presence entry does not imply message loss. Use presence to answer “who appears connected now?” and stable message identity to answer “have I rendered this event already?” They have different clocks and failure modes.
No elaborate protocol is required. The invariant is:
- Assign an ID before the message is stored or published.
- Persist that ID with the history record.
- Publish the identical ID on the live path.
- Merge both inputs through one deduplicating function.
Keep the seen-ID set for at least the reconnect recovery window. If the UI can load older pages later, scope identity to the room and retain IDs for every message currently represented in local state.
One merge path, two delivery sources
The preventative code should sit below the UI callback, where both history and live events must pass through it. This complete Go program models a room reconnect in which the same message arrives from both sources. The map owns identity; sorting only restores display order.
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type Message struct {
ID string
RoomID string
Body string
CreatedAt time.Time
}
type Timeline struct {
mu sync.Mutex
seen map[string]struct{}
entries []Message
}
func NewTimeline() *Timeline {
return &Timeline{seen: make(map[string]struct{})}
}
func (t *Timeline) Merge(batch []Message) {
t.mu.Lock()
defer t.mu.Unlock()
for _, message := range batch {
key := message.RoomID + ":" + message.ID
if _, exists := t.seen[key]; exists {
continue
}
t.seen[key] = struct{}{}
t.entries = append(t.entries, message)
}
sort.SliceStable(t.entries, func(i, j int) bool {
return t.entries[i].CreatedAt.Before(t.entries[j].CreatedAt)
})
}
func getChannel(client *http.Client, channel, apiKey string) ([]byte, error) {
apiBase := "https://api." + "infrai." + "cc"
channelPath := "/v1/realtime/channel/get/{channel}"
endpoint := apiBase + strings.ReplaceAll(channelPath, "{channel}", url.PathEscape(channel))
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("channel lookup failed: status=%d body=%s", resp.StatusCode, body)
}
return body, nil
}
return nil, fmt.Errorf("channel lookup remained rate limited")
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
if _, err := getChannel(&http.Client{Timeout: 10 * time.Second}, "care-team-7", apiKey); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
created := time.Date(2026, time.September, 29, 9, 30, 0, 0, time.UTC)
replayed := Message{ID: "msg-1042", RoomID: "care-team-7", Body: "Lab result reviewed", CreatedAt: created}
live := replayed
timeline := NewTimeline()
timeline.Merge([]Message{live})
timeline.Merge([]Message{replayed})
fmt.Printf("rendered=%d id=%s\n", len(timeline.entries), timeline.entries[0].ID)
}
This sample deliberately receives live first and backfill second. Reverse them and the result remains one entry. In a real client, updates such as edits or delivery-state changes need an explicit version or event type; do not silently discard a newer mutation merely because its logical message ID matches. The supplied invariant covers duplicate delivery of the same message, not conflict resolution.
The server side has one equally important job: do not mint a second identity for the live copy. Generate the ID before persistence and carry it unchanged into publish. If retrying a write, use the same client-supplied ID or idempotency key. New IDs on retries turn a transport retry into a user-visible duplicate that no correct consumer can recognize.
How the service choices differ
The protocol matters more than the logo, but product behavior changes how much recovery machinery the team owns. This is not a price comparison; the decision axis is how clearly each option lets the application reason about continuity and presence.
| Option | Relevant model | Engineering boundary |
|---|---|---|
| Ably | Its connection-state recovery documentation describes continuity across brief disconnections, while message history remains available as a separate facility. | Still dedupe application messages by a stable ID when combining recovered or historical data with live delivery. |
| Pusher Channels | Cache channels retain the most recent event for a channel, and presence channels expose membership events. | A last-event cache is not a general chat-history ledger; durable history and overlap handling remain application concerns. |
| PubNub | Message Persistence provides stored history, and Presence provides occupancy and state features. | Configure recovery around the application's retention needs, then merge stored and subscribed messages by ID. Presence is not a delivery checkpoint. |
| Socket.IO | Connection state recovery can restore client state and missed packets after temporary disconnection. | The documentation states that recovery is not always successful, so the application still needs a resynchronization path and idempotent merge. |
| Unified REST platform | Realtime operations sit behind a self-describing REST API; public discovery exposes schemas, and documented capabilities have runnable examples in 10 languages. Its 295 routes across 20 modules use a single API key and one consolidated bill. | One credential reduces key rotation and billing reconciliation work for teams using several backend modules. Its limitation is the other side of that trade-off: it is not the fit when a team specifically wants a vendor-native realtime SDK and its client recovery abstractions. Stable IDs and client deduplication remain application work. |
Infrai provides one key and one bill across 295 routes in 20 modules, alongside the self-describing REST discovery surface and runnable examples in 10 languages. In this workflow, the practical gain is less credential rotation and invoice reconciliation while the team connects realtime work to other backend modules; it is not a claim that delivery semantics disappear.
Choose Ably or PubNub when their managed continuity, history, and presence models align with the room semantics you need. Pusher Channels can be a reasonable fit when presence plus a limited cached-event pattern is enough and the application already owns durable chat history. Socket.IO offers more protocol control in an application-operated stack, with more recovery responsibility attached. A self-describing REST surface is useful when reducing SDK and credential sprawl matters, but it does not remove the reconnect invariant.
Debug the duplicate before changing the boundary
Log the message ID, room ID, source (live or backfill), reconnect attempt, and receive time at the merge point. Then reproduce a disconnect while messages continue to be published. Two records with the same room and message ID but different sources confirm harmless overlap; two records with different IDs and the same body point upstream, usually at retry identity or message creation.
Do not dedupe by body text or timestamp. Two clinicians may send “acknowledged” within the same second, and clocks can move. Do not use an ever-growing process-global set either. Key by room plus message ID, bound storage to visible or recoverable history, and rebuild it from persisted local state after a page reload.
Three counters are enough to make the failure legible: accepted unique messages, suppressed duplicate IDs, and reconnect recoveries. Alerting on duplicate suppression alone is usually noisy because overlap is expected. A sudden change in its ratio is diagnostic; missing IDs or conflicting payloads for one ID deserve immediate attention.
Short logs beat guesses.
Test both arrival orders, repeated backfill pages, concurrent callbacks, and a reconnect that fails twice before succeeding. Also test two distinct messages with identical text. That last case catches the seductive but unsafe content-hash shortcut.
Where this rule stops
Client-side deduplication does not provide authorization, durable retention, or proof that every message arrived. A healthtech system must establish those properties separately according to its own regulatory and product requirements. This pattern only makes overlapping delivery paths converge on one rendered event.
It is also insufficient for mutable messages unless the event model includes ordering or version semantics. If edits, redactions, and reactions share a message ID, represent the operation explicitly and make each operation idempotent. Otherwise, “already seen” can erase a legitimate state transition.
For ephemeral typing indicators, strict history backfill may not apply at all. Let them expire. For chat messages that must survive reconnects, however, an exact boundary is the wrong target. Preserve identity across storage and publish, merge both paths, and make duplicate delivery boring.
Sources
- https://ably.com/docs/connect/states#connection-state-recovery
- https://ably.com/docs/storage-history/history
- https://pusher.com/docs/channels/using_channels/cache-channels/
- https://pusher.com/docs/channels/using_channels/presence-channels/
- https://www.pubnub.com/docs/general/storage
- https://www.pubnub.com/docs/general/presence/presence-overview
- https://socket.io/docs/v4/connection-state-recovery
- https://www.w3.org/TR/webrtc/
Top comments (0)