Treat typing and read receipts as expiring observations, not durable facts. In a property-management chat, publish a versioned presence snapshot over a persistent connection, let the client render only entries that are still within their freshness window, and reconcile after every reconnect. A resident who stopped typing is not an incident; a stale green dot that survives a lost connection is a trust failure.
Short answer: keep conversation messages durable, but keep typing and read state separate from the message log. Give each snapshot a monotonically increasing version, include the observed-at time and an explicit expiry, and reject older snapshots on the client. The server should publish the complete relevant state when membership changes or a client resynchronizes; ordinary keystrokes can still be coalesced.
Can Node.js Express publish whole queue state and let clients compute position?
Accuracy here does not mean knowing a person's exact physical state. It means the UI never presents an observation as current after the system has lost enough evidence to trust it. That distinction matters at 3am, when a dashboard can look healthy while a connection has quietly disappeared. I distrust dashboards that answer only “are packets moving?” The useful question is “what page fired, and what state can the resident or leasing agent actually see?”
State can be stale.
That is the contract.
For a maintenance conversation, the state might contain one typing observation per participant and one read cursor per participant. A compact example is easier to reason about than a stream of toggles:
type PresenceSnapshot struct { ConversationID string `json:"conversation_id"` Version uint64 `json:"version"` ObservedAt int64 `json:"observed_at"` ExpiresAt int64 `json:"expires_at"` Typing map[string]bool `json:"typing"` ReadUpTo map[string]string `json:"read_up_to"` } The IDs in ReadUpTo should refer to message IDs, not array positions. A position changes when messages are removed, redacted, or loaded from another page; an ID remains an application-level cursor. Version orders snapshots. ObservedAt tells the client when the server formed the observation, while ExpiresAt tells it when to stop displaying it. Those fields answer different questions.
The same rule applies if the property application also has a maintenance queue. A Node.js Express handler can publish the queue snapshot, but the client should compute a resident's display position only from the ordered IDs and the snapshot version it accepted. That is a protocol shape, not an Express feature, so the example below stays runtime-neutral and focuses on the wire contract.
Why are typing and read receipts different signals?
Typing is ephemeral intent. A read receipt is an acknowledgement tied to a specific message. Treating both as booleans creates two predictable mistakes: a typing indicator remains visible after a tab sleeps, and a read mark advances past a message the client never rendered. They can share a transport, but they need different validation and retention rules.
The server can accept a typing update only for a participant who is currently authorized for the conversation. It should replace that participant's previous typing observation, rather than append another event. A read cursor should move forward only according to the application's message ordering rule. If the client sends a cursor for a message outside the conversation, the server should reject it and leave the known cursor unchanged.
This is where “publish the whole state” earns its place. A client that receives only alice_typing=false has no way to know whether it missed bob_typing=true during a reconnect. A snapshot gives it a complete answer for the conversation it is allowed to see. The payload is not a history and should not be used as one.
Recovery needs a baseline.
Do not infer presence from transport liveness alone. A WebSocket connection can be open while the user is away from the tab, and a disconnected browser can reconnect with observations that are older than the server state. The transport carries evidence; it does not turn that evidence into truth.
The safe publication loop
Keep one authoritative presence record per conversation on the server side, guarded by the same authorization boundary used to read the conversation. On a valid update, mutate that record, increment its version, and publish a snapshot. On a membership change, publish a fresh snapshot to the affected clients. On resynchronization, publish the full current snapshot even if no new keystroke arrived.
The essential rule is that the version is assigned by the authority that orders updates. Do not ask each browser to invent a version, and do not use a wall clock as the ordering key. Clocks can be skewed; ordering and freshness are separate concerns. A wall-clock timestamp remains useful as metadata, but it is not a substitute for a sequence.
I initially treated a reconnect as a transport problem. The pager lesson is less flattering: the useful failure is the stale state that the transport makes look normal. A full snapshot costs more bytes than a single toggle, but it makes recovery deterministic; for a small conversation or queue, that trade-off is usually easier to test than a patch stream with an undocumented baseline.
This small publisher shows the contract without coupling it to a commercial service:
type Publisher interface { Publish(conversationID string, payload []byte) error } func publishSnapshot(p Publisher, s PresenceSnapshot) error { if s.Version == 0 || s.ExpiresAt < s.ObservedAt { return fmt.Errorf("invalid presence snapshot") } payload, err := json.Marshal(s) if err != nil { return err } return p.Publish(s.ConversationID, payload) } In production, the state mutation and version assignment need a clear ownership model. A single process-local counter is insufficient once more than one application instance can handle the same conversation. Use the ordering primitive already owned by the system, or route a conversation to one state owner. The important property is one unambiguous order, not a particular database or broker.
Coalesce typing signals at the edge of the protocol. Keystrokes are noisy, and publishing every keypress expands fan-out without improving the meaning of “typing.” The server still needs a refresh path so that a client joining late receives the whole state. Read receipts deserve stricter handling: acknowledge the highest message ID the client has actually displayed, then let the server broadcast the resulting cursor.
There is a boundary. Whole-state publication is a poor fit for an enormous queue or a conversation with very large membership, where repeatedly sending every entry makes payload size and fan-out the dominant problem. In that case, partition the state or send a snapshot token plus a bounded delta protocol, while retaining a full resynchronization path. The loss is simplicity; the gain is controlled transfer volume. Neither choice removes the need for authorization, ordering, or expiry.
What should a client do after a reconnect?
A reconnect is a state boundary. Clear or mark stale local typing indicators, authenticate again, resubscribe only after authorization succeeds, and request a snapshot. Do not replay locally buffered “stop typing” events ahead of that snapshot; they may describe a connection that no longer owns the conversation. When the snapshot arrives, replace the conversation's presence state atomically and ignore any later snapshot whose version is not greater than the accepted version.
The client-side rule is deliberately boring:
type SnapshotGate struct { accepted uint64 } func (g *SnapshotGate) Accept(s PresenceSnapshot) bool { if s.Version <= g.accepted { return false } g.accepted = s.Version return true } func isFresh(now, expiresAt int64) bool { return now < expiresAt } A real client also needs to check that ConversationID matches the currently selected conversation and that the authenticated user is still a member. The UI should hide typing when isFresh is false, even if the last received snapshot remains in memory. For read receipts, an expired presence observation should not erase the durable read cursor; expiration applies to the observation, not to the acknowledgement already accepted by the server.
The failure mode worth testing is not merely “the socket reconnects.” Test the ordering: snapshot 12 arrives, the connection drops, snapshot 14 arrives after resubscription, then delayed snapshot 13 appears. The UI must remain at 14. Also test a conversation switch, a revoked membership, a tab sleeping longer than the typing freshness window, and two devices advancing the same read cursor. These cases expose protocol mistakes that a green connection metric will not.
Verification, rollback, and the page that matters
Before rollout, instrument state transitions rather than only transport counters. Useful measurements include snapshot versions accepted and rejected, resynchronization requests, authorization failures, publish failures, and the age of the newest accepted observation. Keep the labels bounded: conversation IDs and user IDs belong in traces or sampled diagnostics, not in an unbounded metric label set.
The operational alert should describe user impact: a sustained rise in resynchronization failures, a growing age of accepted presence, or publish errors for a material share of active conversations. A raw reconnect count is a clue, not a page.
Roll out the reader before changing the writer. A client that understands versions and expiry can safely consume snapshots produced by the old path if the wire shape is compatible. During the writer rollout, keep the previous publication path available behind a configuration switch, and make the switch disable new presence updates without deleting durable messages or read cursors. Rollback should restore publication behavior; it should not manufacture fresh typing state.
The decision rule is simple: use whole snapshots for recovery and membership boundaries, use versioned updates for ordering, and let expiry remove confidence from ephemeral presence. If the system cannot tell a current observation from an old one, adding another dashboard or another event type will not fix the underlying contract.
References
- W3C, WebRTC 1.0: https://www.w3.org/TR/webrtc/
- IETF, The WebSocket Protocol (RFC 6455): https://www.rfc-editor.org/rfc/rfc6455
- IETF, JSON Merge Patch (RFC 7396): https://www.rfc-editor.org/rfc/rfc7396
- MDN, WebSocket API: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
