Short answer: For a logistics support widget, give each visitor a conversation channel protected by a scoped token, then backfill durable messages and read progress after reconnect while letting typing indicators expire.
That is the split.
Issue a short-lived, conversation-scoped token; authorize every join on the server; resume from a stored cursor after reconnect; backfill durable messages and receipts; and never replay typing events. A reconnect must restore facts, not resurrect old intent. This design favors a little more storage and state-machine work over ambiguous recovery. It is a poor fit for fully peer-to-peer sessions with no durable transcript, and it cannot guarantee that a human actually saw a message; a receipt records client progress, not attention.
This distinction matters when a dispatcher loses connectivity while answering a driver about a missed pickup. The browser may reconnect after the dispatcher stopped typing, and the driver may have read several messages while the dispatcher was offline. Replaying both streams alike can display a stuck typing bubble or move a receipt backward. One is annoying. The other makes the support transcript untrustworthy.
I run scheduled and queued production work with the same reflex: assume delivery can happen twice, then make the consumer safe. Pages caused by missed jobs and duplicate deliveries taught me to distrust a connection as a record of truth. A socket is transport. The database owns recovery.
What must survive a broken connection?
Typing does not need backfill. Send it with a short expiry, such as five seconds, and let the UI remove it locally when that deadline passes. If a stop event disappears, expiry still clears the indicator. Do not write typing events into the conversation log merely to make reconnection easier; stale intent has no business returning from storage.
Messages and read progress are different. Give every durable event a monotonically increasing sequence within its conversation. A visitor keeps the highest sequence it has applied and sends that cursor when reconnecting. The server returns events after the cursor, in ascending order, up to a bounded page size. The client deduplicates by event ID and advances its cursor only after applying an event.
Read state should also move in one direction. Model it as last_read_sequence, not a stream of independent “read” booleans. An update becomes max(current, received), so retrying it is harmless and an out-of-order packet cannot make the UI claim that a previously read message is unread.
Four rules cover the failure surface:
- Durable events have a unique ID and a per-conversation sequence.
- Read progress is monotonic and idempotent.
- Typing expires and is excluded from backfill.
- Authorization is checked at join time and again when durable data is read or written.
These rules are intentionally asymmetric. Trying to make every realtime signal durable creates misleading history; treating every signal as ephemeral loses customer-visible facts.
How should Node.js scope a conversation channel per visitor?
An Express endpoint can mint a signed capability after the application has authenticated the visitor and verified conversation membership. The token should identify the subject, conversation, audience, issuer, issued-at time, expiry, and a narrow permission set. JSON Web Token registered claims define the meanings of aud, exp, iat, iss, and sub; the signature protects the claims, but it does not decide whether the caller should receive them.
Keep the authority narrow. A visitor token that can join arbitrary channel names turns a predictable identifier into an access-control bug. The server derives the allowed channel from the verified conversation ID, and the realtime gateway compares that value with the requested join. Client-provided identity is never authoritative.
package chat
import (
"errors"
"time"
)
type Claims struct {
Subject string
Conversation string
Audience string
ExpiresAt time.Time
Permissions map[string]bool
}
func AuthorizeJoin(c Claims, requestedConversation string, now time.Time) error {
if now.After(c.ExpiresAt) {
return errors.New("token expired")
}
if c.Audience != "support-realtime" || c.Conversation != requestedConversation {
return errors.New("scope mismatch")
}
if !c.Permissions["conversation:subscribe"] {
return errors.New("subscription denied")
}
return nil
}
Use an established JOSE implementation to verify the signature and reject unexpected algorithms before constructing these claims. RFC 8725 documents why algorithm verification and mutually exclusive validation rules matter. Token lifetime is an operational choice, not a magic number: it should limit exposure without creating a synchronized reconnect storm. Refresh through the authenticated application session, not through an expired realtime token.
Channel authorization does not end at subscription. A backfill request must apply the same tenant, conversation, and subject checks before querying storage. Closing a socket after access is revoked limits future live delivery; the data path still has to reject later reads. This is a real trade-off: repeated authorization adds lookup work, but trusting a join decision forever leaves a gap after reassignment or revocation. Cache membership only within a deliberately bounded lifetime, and invalidate that cache when the application changes access.
Make replay boring
The preventative path is a small state machine. It accepts duplicates, refuses regressions, and separates durable recovery from ephemeral presence.
package chat
import "time"
type Event struct {
ID string
ConversationID string
Sequence uint64
Kind string
ExpiresAt time.Time
}
type Cursor struct {
LastApplied uint64
Seen map[string]struct{}
}
func (c *Cursor) Apply(e Event, now time.Time) bool {
if e.Kind == "typing" {
return e.ExpiresAt.After(now) // Render live; never advance durable recovery.
}
if _, duplicate := c.Seen[e.ID]; duplicate {
return false
}
if e.Sequence <= c.LastApplied {
return false
}
c.Seen[e.ID] = struct{}{}
c.LastApplied = e.Sequence
return true
}
func AdvanceRead(current, received uint64) uint64 {
if received > current {
return received
}
return current
}
In production, the server should not trust a client to invent sequence numbers. Allocate them transactionally with the durable event, and enforce uniqueness for both (conversation_id, sequence) and the idempotency key supplied with a message command. If a retry reaches another process, the database constraint remains the referee.
There is a subtle gap between fetching backlog and attaching to the live stream. Avoid it with an ordered handoff: establish a server-side subscription boundary, record its sequence, fetch through that boundary, then release buffered live events after the client acknowledges the backfill. An alternative is to subscribe first, buffer by sequence, fetch from the cursor, merge, deduplicate, and then switch to live delivery. Either works if the boundary is explicit and the buffer is bounded.
Backfill needs pagination and retention behavior. Return a page plus the next cursor. If the requested cursor predates retained history, return an explicit reset condition and a current snapshot rather than silently skipping data. For this widget, that snapshot can include the visible message window and each participant's maximum read sequence. It should not include old typing state.
How do you test the reconnect boundary?
The happy-path socket test proves little. Exercise the orderings that make an on-call shift unpleasant: disconnect after the server commits a message but before the acknowledgment arrives; reconnect while a receipt update is in flight; deliver the same event twice; send sequences 42, 44, then 43; expire the token during a live session; revoke membership before a backfill request; and request a cursor older than retention.
Assertions should describe invariants rather than transport calls. No durable event appears twice in the rendered transcript. Applied sequence never decreases. Read progress never decreases. Expired typing disappears without a stop packet. A principal cannot join or backfill another conversation. Backlog and buffered live delivery have neither a gap nor an unexplained duplicate.
I would deploy this behind per-conversation recovery metrics before enabling it for every support queue. Track reconnect attempts, backfill page count, duplicate events rejected, cursor-reset responses, authorization denials, and the distance between the client's cursor and the current durable sequence. Alert on sustained changes in rates, not a single reconnect. Mobile networks reconnect.
Logs need stable correlation fields: request ID, connection ID, subject ID, conversation ID, token issuer, last cursor, returned sequence range, and outcome. Do not log the bearer token or message body. A trace spanning token minting, join authorization, durable write, publish, and backfill makes the boundary visible without turning sensitive chat into telemetry.
Compare mechanisms by failure semantics
The protocol choice comes after the recovery contract. WebSocket supplies a bidirectional connection, while Server-Sent Events supplies a server-to-client event stream and browsers reconnect it automatically. WebRTC data channels can carry peer-to-peer arbitrary data, but their connection establishment and authorization model still do not replace durable application history. None of these mechanisms defines your receipt monotonicity, retention policy, or conversation membership. WebSocket's limitation is that the application owns reconnection, resumption, and heartbeat policy. SSE is one-way and uses separate requests for client writes. WebRTC adds signaling and connectivity concerns that a support-text widget may not need.
Use WebSocket when the widget needs frequent bidirectional low-latency traffic over one session. SSE plus ordinary requests can be easier when server-to-client updates dominate and separate upload requests are acceptable. WebRTC makes sense when peer-to-peer media or data is a real requirement and the additional signaling and network traversal work is justified. Choose transport for traffic shape; choose storage semantics for recovery.
The boundary matters.
This advice does not apply unchanged to collaborative text editing, where operations may require an operational transformation or conflict-free replicated data type rather than a simple sequence cursor. It also does not turn a typing indicator into a compliance record. If the business later declares a signal auditable, promote it to a durable event with explicit retention and privacy rules instead of quietly extending its expiry.
The release checklist is short enough for a runbook: verify scoped claims and server-side membership, enforce idempotency constraints, test the backlog/live handoff, define cursor reset behavior, cap pages and buffers, redact telemetry, and practice key rotation plus access revocation. Reconnect is not an edge case. In a logistics workflow, it is the point where the system proves whether “delivered” and “read” mean anything.
Top comments (0)