A reconnect is a data reconciliation operation, not a transport event. If an e-commerce editor can receive live updates while it is fetching missed history, the two paths will overlap; choose stable event identity, subscribe before backfilling, and merge both paths through one idempotent consumer.
TL;DR: Preserve one event ID from creation through storage and delivery, attach an ordered room sequence on acceptance, and keep a per-room high-water mark at the client. On reconnect, open the live stream first, buffer it, fetch durable events after the saved sequence, then sort and apply the union once by event ID. Do not try to make the history boundary exact. Cursor movement may be coalesced as disposable state, but chat messages and editor annotations need durable identity and replay semantics.
This distinction matters in a collaborative merchandising editor. Several buyers may edit a product launch page, move cursors through the same description, and leave a chat note about an image license. A repeated cursor position is mostly visual noise. A duplicated chat note looks like a second human action, can provoke a second reply, and corrupts the audit trail. The reconnect path must therefore classify events before it decides what to retain.
Overlap is normal.
How should you debug duplicate chat messages after reconnect?
Consider a bounded design exercise rather than a claimed production incident. A browser has applied room sequence 4107 and loses connectivity. While it is away, sequences 4108 through 4113 are committed. It reconnects, starts a live subscription, and asks for history after 4107. Sequence 4112 reaches the live socket while the history response contains 4108 through 4113. If the UI appends both inputs, 4112 appears twice. Reverse the order of subscribe and backfill and the failure changes: an event committed between the history read and the subscription can disappear instead.
The invariant is compact: delivery paths may overlap, but logical application may not. At-least-once delivery is acceptable when application is idempotent. Attempting to promise an overlap-free handoff pushes coordination into the broker, history store, and every reconnecting edge; it also leaves retries unresolved, because a lost acknowledgment can still make a correct sender retransmit.
Transport identity does not solve logical identity. A newly created socket, peer connection, or process-local callback has a new lifetime. WebRTC, for example, defines peer connections and data channels, but an application still owns the meaning and persistence of its messages. A reconnect token can name a session generation, yet a durable chat event needs an ID that survives every generation.
Use three separate values:
-
event_ididentifies the logical action and is created once, before the first send. A retry reuses it. -
room_seqis assigned when the room accepts the durable event and gives replay a stable order. -
connection_epochidentifies one transport lifetime for diagnostics and rejection of callbacks from an obsolete connection.
Do not substitute timestamps. Two writers can share a timestamp, clocks can disagree, and receipt time changes across delivery paths. Do not mint a new ID in the relay either; that converts one retry into two apparently valid actions.
Keep the ID.
Put history and live traffic through one gate
The preventative path should be boring. The reconnect coordinator subscribes first, buffers live durable events, reads history after the persisted high-water mark, and then sends the combined set through the same merge function used for normal delivery. The example below deliberately operates on durable events. High-frequency cursor positions belong in a separate latest-state path.
package replay
import (
"sort"
"sync"
)
type Event struct {
ID string
Room string
Sequence uint64
Kind string
Payload []byte
}
type Merger struct {
mu sync.Mutex
room string
highWater uint64
seen map[string]struct{}
}
func NewMerger(room string, highWater uint64) *Merger {
return &Merger{
room: room,
highWater: highWater,
seen: make(map[string]struct{}),
}
}
// ApplyBatch returns durable events exactly once and in room order.
func (m *Merger) ApplyBatch(history, bufferedLive []Event) []Event {
m.mu.Lock()
defer m.mu.Unlock()
batch := append(append([]Event(nil), history...), bufferedLive...)
sort.SliceStable(batch, func(i, j int) bool {
return batch[i].Sequence < batch[j].Sequence
})
out := make([]Event, 0, len(batch))
for _, event := range batch {
if event.Room != m.room || event.ID == "" {
continue
}
if _, duplicate := m.seen[event.ID]; duplicate {
continue
}
m.seen[event.ID] = struct{}{}
if event.Sequence <= m.highWater {
continue
}
out = append(out, event)
m.highWater = event.Sequence
}
return out
}
func (m *Merger) HighWater() uint64 {
m.mu.Lock()
defer m.mu.Unlock()
return m.highWater
}
This sample assumes the history query returns a contiguous ordered range and that accepted durable events have nonzero sequences. Those are contracts to test, not ambient properties of a socket. It also keeps seen in memory for clarity. A real client should bound that set by the replay retention window or persist enough IDs with the high-water mark; deleting IDs merely because a connection closed recreates the bug.
There is another trap in the order of checks. Marking an event as seen before rejecting it by room is wrong because a malformed cross-room delivery could poison a valid ID. Conversely, recording a valid old event before the high-water check is useful: if the same old event arrives repeatedly, diagnostics can distinguish duplicate delivery from a new out-of-order ID. Decide this behavior deliberately and test it.
Connection epochs protect the UI from stale asynchronous work. When epoch 18 replaces epoch 17, a callback captured under 17 must not mutate the active room, even if it finishes late. Epochs prevent cross-connection races; event IDs prevent duplicate logical actions. Neither replaces the other.
Different jobs.
Treat cursors and chat as different data classes
A cursor is usually latest-state data. If positions 81, 82, and 83 arrive together for one collaborator, rendering 83 is enough; after a long disconnect, displaying no cursor until a fresh presence update is often more honest than replaying a path across an old document revision. The useful key is collaborator plus document revision, and the useful operation is replacement.
A chat message is an event. Its wording, author, parent message, and order remain relevant after reconnect. The useful key is the client-created event ID, and the useful operation is insert-if-absent. Editor annotations, approvals, and publish-state changes normally belong with chat, not cursors, because losing or duplicating them changes business meaning.
That classification also keeps the capacity plan sane. Suppose a planning envelope has 10,000 connected editors, each emitting 10 cursor positions per second during a promotion launch. The ingress estimate is 100,000 disposable updates per second before protocol overhead. Persisting every position and replaying it after reconnect would inflate storage and recovery work without improving the final cursor state. Coalescing by collaborator bounds work near the number of active collaborators, while durable chat volume remains governed by actual human actions. These are example inputs for sizing, not benchmark results. Replace them with observed concurrency and event rates.
Set separate service objectives. For durable events, track reconnect completion latency, missing-sequence rate, and duplicate-application rate; the last two should have a zero-error objective at the application boundary, with alerts based on any sustained violation rather than transport retransmissions. For cursor state, track freshness and drop stale updates by document revision. One blended "realtime latency" chart hides the failure users actually notice.
Choose the ownership boundary before choosing machinery
The hard buy-versus-build question is not who can open a socket. It is who owns stable identity, ordered replay, retention, and the pager when reconciliation stalls. A managed connection layer can reduce fleet work, but the application still needs domain IDs and idempotent writes. A self-hosted relay gives control over retention and placement while adding upgrades, partitions, and capacity to the on-call queue. Peer data channels can reduce relay traffic for cursor state, but durable chat still needs an authoritative persistence and replay path.
| Approach | Reconnect and backfill boundary | On-call load | Lock-in pressure | Good fit |
|---|---|---|---|---|
| Managed connection layer | Provider carries connections; application owns event identity and durable merge | Lower transport operations, unchanged domain correctness | Protocol and replay APIs may shape clients | Small platform team with explicit adapter and export tests |
| Self-hosted relay plus event store | Team owns connection fleet, ordering contract, retention, and recovery | Highest; capacity and failure drills stay internal | Lower if wire and storage formats remain portable | Team that needs placement control and can staff the pager |
| Peer data channel plus durable service | Peers carry disposable state; service owns authoritative events | Mixed; peer diagnostics remain, durable path still operates | Signaling and client behavior can become coupled | Cursor-heavy workloads with a clearly separate chat path |
My decision rule is skeptical: pick the smallest operational boundary that preserves application-owned IDs and an exportable event log, then prove reconnect behavior through the adapter. I would reject any design review that presents "automatic reconnection" without specifying subscription order, replay cursor semantics, retention, and duplicate handling. Those omissions become incidents regardless of where the connection fleet runs.
Test the contract with adversarial schedules. Deliver the same event through history and live paths; delay an old epoch until after a new one is active; lose the acknowledgment after acceptance; reconnect beyond retention; and interleave two writers. The assertion belongs at rendered or persisted application state: one logical chat message, the correct final cursor for the current document revision, and a high-water mark that never moves backward. Run these cases during deployment against the adapter, not only against a mocked callback.
When should this advice not apply?
The first limitation is data lifetime. Do not build durable replay for data whose only meaning is "now." Cursor trails, typing indicators, and transient selection highlights can expire, be coalesced, or be replaced by a current snapshot. Stable IDs may still help diagnostics, but a history store adds recovery load without restoring useful state. The trade-off is explicit: replay improves recovery for business events while increasing storage, read amplification, and the state a client must reconcile. For disposable presence, that cost does not fit the job.
The ordered high-water design also needs adjustment when events have no single room authority. If independent regions can accept concurrent writes, one scalar sequence cannot describe every causal relationship. Use the domain's conflict rule and convergence model, then retain stable operation IDs for deduplication. Pretending that a timestamp is a global sequence only moves ambiguity into the client.
There is a clean closing test: after any disconnect point, replaying an arbitrary overlap of old and new deliveries must produce the same durable state as applying each accepted logical event once in authoritative order. Cursor state may skip intermediate positions. Chat may not duplicate. That is the reconnect contract worth putting on the pager.
Sources
- W3C, WebRTC 1.0: Real-Time Communication Between Browsers: https://www.w3.org/TR/webrtc/
Top comments (0)