TL;DR: For a live care-team dashboard, push typing indicators as disposable events, but treat read receipts and aggregate metrics as recoverable state. Send a tiny invalidation message with a monotonic version, then let each client query the authoritative snapshot once. This hybrid gives fast fan-out without pretending that every connected browser received every update.
A pure push design looks efficient until a clinician's laptop sleeps between versions 41 and 57. A pure query design is easier to reason about, but polling makes idle screens ask questions whose answers have not changed. The useful decision is not channel versus API. It is which data may vanish, which data must converge, and how a client proves that it is current.
What must the dashboard actually guarantee?
Typing is an expiring hint. If user-17 is typing disappears in transit, the next hint or a short local timeout repairs the display. Replaying an old typing event is worse than dropping it because stale activity can look current. I would not spend durable delivery machinery on it.
A read receipt is different. A care-team member who opens a message expects the shared view to settle on durable state: who has read it, and through which message sequence. Dashboard counts derived from those receipts must settle too. The UI can be briefly stale; it cannot remain wrong after reconnect.
That split produces three explicit contracts:
- Typing events are best-effort, replaceable, and bounded by an expiry time.
- Read state is idempotent, stored before publication, and recoverable by query.
- Dashboard metrics are snapshots identified by a monotonically increasing version.
The order matters. Persist the read state and its new version first. Publish an invalidation second. If fan-out fails, the stored snapshot is still correct and a reconnect can recover it. Publishing first creates a notification for state that may not exist yet. No client-side retry can fix that race.
The channel is a wake-up signal, not the database. That constraint removes a surprising amount of configuration: no durable per-browser queue, no replay log for transient typing, and no attempt to make a socket session outlive the browser that owns it.
Should a Channel Push Metrics or Let Each Client Query?
Imagine one conversation viewed by 40 care-team clients. A receipt advances the unread aggregate from version 208 to 209. Sending the entire metric payload to all 40 clients is fast on the happy path, but it quietly assigns correctness to 40 independent deliveries. One suspended tab misses the message. Another receives version 209 after 210. A third reconnects with no evidence that anything happened.
Sequence numbers expose those gaps, but they do not fill them. Retaining and replaying every aggregate update can fill them, at the price of retention rules, cursor storage, compaction, access checks during replay, and more failure modes. That machinery is justified for an audit stream. It is config bloat for a replaceable dashboard snapshot.
Loss is normal.
Polling the metrics API every few seconds avoids lost channel messages, yet request volume follows connected clients and polling frequency rather than actual changes. It also forces an awkward choice between stale displays and frequent requests.
The hybrid contract is smaller. A channel message says conversation c-184 changed; latest version is 209. A client already at 209 does nothing. A client below 209 schedules one snapshot query. Ten invalidations that arrive together still produce one query.
There is a catch: a notification can arrive while a query is in flight. The client must compare versions again after the response. Otherwise, it can fetch version 209, miss the invalidation for 210 during that request, and call itself current. This edge is where tidy architecture diagrams tend to stop being useful.
Test that race.
The smallest implementation that converges
I want the client contract to fit on one screen. Two message shapes are enough, and neither exposes patient data. The channel carries identifiers, versions, and ephemeral activity only. Authorization belongs on both the subscription and snapshot query paths.
type TypingEvent = {
kind: "typing";
conversationId: string;
actorId: string;
expiresAt: number;
};
type MetricsInvalidation = {
kind: "metrics-invalidated";
conversationId: string;
version: number;
};
type MetricsSnapshot = {
conversationId: string;
version: number;
unreadMessages: number;
readers: ReadonlyArray<{ actorId: string; throughSequence: number }>;
};
The store operation should accept a receipt key that makes retries harmless. Its transaction must produce one committed receipt and one resulting aggregate version even when the caller repeats a command. Publication happens only after commit.
type ReadCommand = {
receiptId: string;
conversationId: string;
actorId: string;
throughSequence: number;
};
interface ReceiptStore {
commit(command: ReadCommand): Promise<{ version: number }>;
}
interface Fanout {
publish(message: MetricsInvalidation): Promise<void>;
}
async function recordRead(
command: ReadCommand,
store: ReceiptStore,
fanout: Fanout
): Promise<void> {
const { version } = await store.commit(command);
await fanout.publish({
kind: "metrics-invalidated",
conversationId: command.conversationId,
version
});
}
A production publisher still needs a way to retry publication after a process stops between commit and publish. A transactional outbox is one common shape: commit the receipt and an unpublished notification record together, then let a worker publish and mark that record. Duplicate publication remains possible, so consumers still compare versions. The important property is eventual publication, not an exactly-once label.
On the client, coalescing is the feature. This implementation permits one active fetch per conversation and checks the highest requested version before declaring success.
type FetchSnapshot = (conversationId: string) => Promise<MetricsSnapshot>;
class MetricsFollower {
private appliedVersion = new Map<string, number>();
private wantedVersion = new Map<string, number>();
private active = new Map<string, Promise<void>>();
constructor(
private readonly fetchSnapshot: FetchSnapshot,
private readonly render: (snapshot: MetricsSnapshot) => void
) {}
invalidate(message: MetricsInvalidation): Promise<void> {
const id = message.conversationId;
const wanted = Math.max(this.wantedVersion.get(id) ?? 0, message.version);
this.wantedVersion.set(id, wanted);
const running = this.active.get(id);
if (running) return running;
const task = this.catchUp(id).finally(() => this.active.delete(id));
this.active.set(id, task);
return task;
}
private async catchUp(conversationId: string): Promise<void> {
while ((this.appliedVersion.get(conversationId) ?? 0) <
(this.wantedVersion.get(conversationId) ?? 0)) {
const snapshot = await this.fetchSnapshot(conversationId);
const applied = this.appliedVersion.get(conversationId) ?? 0;
if (snapshot.version > applied) {
this.render(snapshot);
this.appliedVersion.set(conversationId, snapshot.version);
}
}
}
}
One detail is deliberately absent: a timer that queries forever. Fetch on initial load, on a newer invalidation, on reconnect, and after a retryable fetch failure. Add randomized backoff for failures and a request timeout. Stop retrying when authorization is denied or the conversation is no longer visible.
Typing uses a smaller path. Accept an event only if its expiry is in the future, replace the actor's prior expiry, and remove the indicator locally at that time. Do not replay it after reconnect. Quietly discarding an expired hint is correct behavior.
How do you test fan-out failure instead of the happy path?
Benchmarking message rate alone answers the easy question. I would put correctness first and make the transport misbehave on purpose. For one test run, use 40 simulated clients, 10 conversations, a burst of 200 receipt commands, and fixed fault injection: drop every seventh invalidation, duplicate every eleventh, delay every thirteenth by 800 milliseconds, and disconnect five clients for the middle of the run. These are test inputs, not performance claims.
The invariant is crisp: after writes stop, publication retries finish, and connected clients complete their snapshot requests, every client must hold the store's latest version for each conversation it may view. Arrival order must not matter. Duplicate receipt commands must not advance read state twice. An expired typing event must never reappear after reconnect.
One test deserves more room. Disconnect a client after it applies version 208, commit versions 209 through 214, and allow the publisher to drop two invalidations while the client is away. On reconnect, deliver only the invalidation for 214. The client should make one snapshot request, render 214, and stay there when a delayed duplicate for 211 arrives. Then repeat the case with 215 committed during that request. The loop must observe that its wanted version moved, fetch again, and finish at 215. This sequence checks recovery, deduplication, old-message rejection, and the in-flight race without requiring six separate happy-path tests.
Measure time from committed receipt to converged display, snapshot requests per committed version, invalidations coalesced into an existing request, and clients lagging behind the authoritative version. The last metric needs a version comparison; a green socket status proves almost nothing.
Also test authorization changes during a live session. If a clinician loses access, the next snapshot query must fail closed, cached data must leave the view, and the subscription must stop producing useful messages. Keep sensitive names, message text, and clinical details out of invalidation payloads so a late channel delivery has little to reveal.
This is the trade-off I would accept: a dashboard may show an older count during a partition, but it advertises no false guarantee and converges from authoritative state afterward. For read receipts, that is more defensible than hiding loss behind a permanently connected icon.
What I would change at scale
First, shard fan-out by tenant and conversation, then preserve ordering only within the key that owns a version. Global ordering buys nothing here. If invalidations become hot, publish a high-water mark rather than one message per receipt and debounce client queries over a short window. The debounce is load control, not a correctness mechanism.
Second, separate durable receipt storage from derived aggregates. A background projection may compute dashboard metrics, but its snapshot version must describe the projection the client actually reads. Do not send source-event version 500 while the query can return only projection version 493; that produces a client loop with no route to 500.
Third, set an explicit retention decision. If regulations or product requirements demand a full audit history, retain receipt commands in the system of record and build audit access around that record. Do not infer history from whatever happened to cross a live channel.
Would I replace the snapshot query with a fully replayable stream? Only when clients need every intermediate transition, offline processing is a real requirement, and the team is willing to own cursors, retention, replay authorization, and compaction. A live count does not usually clear that bar.
WebRTC data channels illustrate why transport labels are insufficient. The standard defines data channels with configurable ordering and retransmission behavior over an SCTP association. Those controls affect delivery across a peer connection; they do not make application state durable across a closed session, authorize a reconnecting user, or define the snapshot that repairs a missed update. Whatever channel transport you choose, the application still needs its convergence contract.
Push the hint. Query the truth. Version both sides. That rule keeps typing fast, read receipts recoverable, and the live clinical dashboard honest about what fan-out can guarantee.
References and Further reading
- W3C, WebRTC 1.0: Real-Time Communication Between Browsers: https://www.w3.org/TR/webrtc/
Top comments (0)