Short answer: when designing reliable updates for a collaborative whiteboard, make the realtime channel lifecycle treat typing indicators and read receipts as expiring evidence, not durable state; bind every signal to a participant session and channel epoch, then reject late evidence at the receiver.

That decision keeps presence accuracy honest without asking an ephemeral channel to provide ledger semantics. A canvas operation may deserve durable sequencing, replay, reconciliation, and an audit trail. “Mina is typing” does not. A read receipt sits between those categories: it can affect user expectations, but it reports an observation rather than changing the board. Mixing all three into one undifferentiated stream makes reconnect behavior look easy while quietly allowing a stale packet to resurrect a departed participant.

This is an architecture decision record for a collaborative whiteboard used in a fintech workflow, where a reviewer may annotate a settlement diagram while another participant reads it. The narrow decision is how to add typing indicators and read receipts when presence accuracy is more important than preserving every transient signal. The design does not claim exactly-once delivery. It applies an exactly-once mindset instead: identify the business observation, give it a stable identity, define which duplicate or late observations are admissible, and retain only the evidence required for reconciliation.

How should a realtime channel lifecycle keep collaborative whiteboard updates reliable?

Use two logical lanes even if one physical connection carries both. The durable lane owns canvas operations such as adding a stroke, moving a shape, or changing an annotation. The ephemeral lane owns typing state, cursors, and connection presence. Read receipts cross the boundary carefully: store a monotonic acknowledgement of a durable board revision, while delivering the live “seen” decoration as an ephemeral update.

The channel lifecycle is detached -> joining -> active -> draining -> detached. Each successful join creates a new, server-issued channel epoch associated with an authenticated participant session. Every transient message carries the board ID, participant ID, session ID, epoch, kind, and expiry time. A receiver accepts it only when the board and current epoch match and the expiry time has not passed. Reconnection never replays a typing indicator from an earlier epoch.

That last rule matters more than transport choice.

For example, suppose a reviewer begins typing on board brd_4821, loses connectivity, and reconnects in another tab. The first session's delayed typing=true packet may arrive after the second session has already sent typing=false. Ordering by a connection-local counter alone cannot distinguish the old session from the new one. An epoch can. The receiver discards the older epoch, so the UI does not show a reviewer typing forever; separately, a short expiry removes the indicator if the terminal event is lost. No cleanup event is treated as guaranteed.

The lifecycle has four invariants:

  1. A canvas mutation is acknowledged only after it enters the durable ordering domain; an animated local preview is not an acknowledgement.
  2. Within one participant session and epoch, sequence numbers only increase. Duplicate sequence numbers are harmless.
  3. A typing indicator expires unless refreshed. Silence can remove presence, but it cannot confirm presence.
  4. A read receipt advances to a higher durable board revision and never moves backward, even when delivery order does.

There is a subtle distinction between channel liveness and participant presence. A WebRTC peer connection exposes connection state, but application presence still needs an application-level definition: which authenticated session, on which board, observed at what time? Transport state can inform that decision; it cannot replace it. The W3C WebRTC Recommendation also defines data channels and their configurable ordering and retransmission behavior, which is useful evidence when selecting a path, but the application must still specify expiry, identity, and convergence.

Invariants, failure boundaries, and the evidence worth keeping

The failure boundary is the channel epoch. Everything before the current epoch is historical, including messages that the network delivers later. Everything within the current epoch is considered only under its per-kind rule. A durable canvas operation may be reconciled by operation ID and board revision. A typing update may be overwritten by a newer sequence and then expire. A receipt may advance an acknowledgement watermark.

This is intentionally asymmetric. Trying to make a transient “stopped typing” event durable produces an audit record with little compliance value and a large semantic trap: replaying it later changes a UI that exists now, not the UI that existed when the event was recorded. Conversely, leaving a read watermark only in memory can make a reconnect show already-reviewed work as unread. The event name is not enough to choose durability; ask whether the observation must survive a new authenticated session and whether replay still means the same thing.

The audit trail should record lifecycle transitions and durable acknowledgements, not a transcript of every keystroke-adjacent pulse. A useful transition record contains the board ID, pseudonymous participant ID, session ID, previous and next epoch, transition reason, and server observation time. Retention and access have to follow the organization's actual compliance policy; I'm not sure a universal retention period exists for this metadata, because jurisdiction, purpose, and internal control design change the answer. A compliance owner should resolve that question before production retention is configured.

Keep the diagnostic vocabulary small. join_accepted, join_rejected, epoch_advanced, signal_expired, stale_epoch_dropped, and receipt_advanced are enough to reconstruct why a user saw a presence state without turning raw message bodies into logs. Count drops by reason, measure the age of accepted signals, and alert on a sustained divergence between active authenticated sessions and visible participants. Do not put participant names or typed text into those metrics.

There are three boundaries worth testing because each permits a different false claim:

  • At authentication, an unbound connection could claim another participant's presence.
  • At reconnection, an old epoch could overwrite the current session.
  • At rendering, an expired signal could remain visible after the channel disappears.

The tests should use a deterministic clock and deliberately permute delivery. Send typing=true at sequence 7, reconnect into a higher epoch, then deliver sequence 8 from the old epoch; the result must remain absent. Deliver receipt revision 104 after revision 109; the stored watermark must remain 109. Deliver the same canvas operation ID twice; the durable lane must expose one logical mutation. These are model-level assertions, not latency benchmarks, so they remain useful across transport implementations.

Choosing a channel shape without confusing transport and correctness

The transport decision follows the topology and failure model. It does not define the lifecycle.

Channel shape Presence accuracy burden Recovery model Suitable use Principal limitation
Server-mediated connection Server can issue epochs and reject stale sessions centrally Rejoin, obtain current durable revision, then resume transient signals Regulated collaboration where one policy boundary should authorize and observe sessions The service owns connection fan-out and must be operated as a critical dependency
Peer data channels with a coordination service Each peer must apply the same epoch and expiry rules Re-coordinate peers and reconcile durable state through a separate authority Small rooms where direct peer exchange is justified by topology Peer membership changes multiply connection and reconciliation paths
Periodic request/response Presence is sampled rather than continuously pushed Next poll replaces the previous view Low-activity review screens that tolerate visibly delayed indicators Poor fit for fluid typing feedback and rapidly changing rooms

WebRTC data channels can be configured for ordered or unordered delivery and with retransmission constraints. That flexibility does not imply that one configuration is correct for every message. A cursor or typing refresh can tolerate loss because a newer refresh supersedes it; a durable canvas operation needs an independent reconciliation path even if the selected channel is ordered. The design should therefore expose message semantics above the transport instead of allowing a transport flag to become a business guarantee.

The catch is operational concentration. A server-mediated channel is not suitable when the requirement is direct media or data exchange with no application relay, or when room topology makes centralized fan-out the dominant constraint and the team is prepared to operate peer coordination. Stick with periodic request/response when indicators are incidental, update frequency is low, and the simpler failure surface is worth delayed presence. The rejected options are valid in those conditions; they are rejected here because presence accuracy, centralized authorization, and auditable session transitions dominate.

Cost should be modeled from concurrent authenticated sessions, fan-out per room, durable operation volume, retention, and observability cardinality. Counting only messages hides the expensive dimensions. It also encourages a false optimization in which typing pulses share durable storage with canvas operations, inflating write and retention load while making replay semantics worse.

The critical path in Go

The core can remain independent of the connection library. The following receiver accepts a server-established epoch, uses a monotonic sequence within that epoch, expires transient evidence, and advances read receipts by durable revision. Authentication, persistence, and fan-out surround this function; they are deliberately outside it so the lifecycle rules can be tested as a pure state transition.

package presence import ( "errors" "time" ) type Kind string const ( Typing Kind = "typing" ReadReceipt Kind = "read_receipt" ) type Signal struct { BoardID string ParticipantID string SessionID string Epoch uint64 Sequence uint64 Kind Kind ExpiresAt time.Time BoardRevision uint64 } type ParticipantState struct { SessionID string Epoch uint64 LastSequence uint64 TypingUntil time.Time ReadRevision uint64 } var ( ErrWrongBoard = errors.New("signal belongs to another board") ErrStaleEpoch = errors.New("signal belongs to a stale channel epoch") ErrExpired = errors.New("signal has expired") ) func Apply(now time.Time, boardID string, state ParticipantState, signal Signal) (ParticipantState, error) { if signal.BoardID != boardID { return state, ErrWrongBoard } if signal.SessionID != state.SessionID || signal.Epoch != state.Epoch { return state, ErrStaleEpoch } if !signal.ExpiresAt.After(now) { return state, ErrExpired } if signal.Sequence <= state.LastSequence { return state, nil // Duplicate or reordered evidence changes nothing. } state.LastSequence = signal.Sequence switch signal.Kind { case Typing: state.TypingUntil = signal.ExpiresAt case ReadReceipt: if signal.BoardRevision > state.ReadRevision { state.ReadRevision = signal.BoardRevision } } return state, nil } 

The returned errors are classification results for metrics and protocol responses; they are not evidence of a service outage. The caller should not retry a stale epoch. It should complete a fresh authenticated join, receive the current epoch and durable board revision, and then emit new transient state. By contrast, persistence acknowledgement for a canvas operation belongs to the durable path and must be tied to its operation ID.

One implementation detail deserves scrutiny: a single LastSequence works only if all ephemeral kinds share one ordered sender stream. If typing and receipts can travel independently, keep a last sequence per kind or per logical lane. This is the sort of innocent simplification that passes a happy-path demo and fails under reordering; state the assumption in the protocol and encode it in tests.

Deployment should preserve compatibility across rolling versions. Additive fields can be ignored until both sides understand them, but an epoch interpretation change requires an explicit protocol version negotiated at join. Drain old connections before retiring their version, while continuing to reject any message whose session binding or epoch does not match current state. Dashboard the rejection reasons before rollout, during the mixed-version interval, and after completion.

Decision and rejected alternative

Adopt a server-mediated channel with separate durable and ephemeral semantics, server-issued epochs, expiring typing evidence, and monotonic read-revision watermarks. It fits this whiteboard because authenticated review sessions, accurate presence, reconciliation, and a restrained audit trail matter more than direct peer topology. The decision is about authority and failure containment, not a promise that every packet arrives once.

We reject a full peer mesh for this workload, although WebRTC data channels are a legitimate mechanism and the standard defines the relevant connection and channel primitives. A mesh remains a valid choice for small rooms whose direct exchange requirement outweighs centralized observability and whose team can validate membership changes, epoch convergence, and durable recovery independently. We also reject polling for the active editing surface, but retain it as a sensible fallback for low-activity review pages where delayed indicators are acceptable.

The final acceptance test is blunt: after any disconnect, duplicate, reordering, or tab replacement, the visible typing state must describe only the current authenticated epoch, and the read watermark must equal the greatest durable board revision the participant has acknowledged. If the system cannot explain those two values from its transition records without replaying transient payloads, the lifecycle is not ready.

References