A video consultation room can look healthy while its participant list is lying. A dropped tab may remain marked online; a reconnected clinician may receive an old roster; a public channel may accept a presence update from a client that never joined the visit. Those are failure-handling problems, not cosmetic UI bugs.
Short answer: treat presence as an expiring, server-authorized observation, and make clients reconcile it after every reconnect. Keep authentication, subscription state, and business events in separate logs and metrics. Use the realtime presence read for the channel, then let the room server decide what “online” means for a consultation.
Before this model, the browser broadcasts joined and left events and everyone trusts the last event they saw. After it, the browser sends a heartbeat, the server assigns a stable participant/session identifier, and a reader considers a participant online only while that observation is fresh. The list becomes a projection that can be rebuilt, not a permanent fact.
What does a secure public channel mean in a consultation room?
“Public” should describe discovery of the channel, not unlimited authority. A room slug can be discoverable while every presence write still requires a short-lived room capability issued by the application. Keep the capability scoped to one visit and one participant role. Do not put a patient name or medical note in a channel name; public metadata tends to leak into logs, browser history, and support screenshots.
The server owns membership. It checks that the visit is active, that the caller is allowed to see the room, and that the token has not expired. The media plane has its own authorization and signaling state. Presence is a third stream. Mixing them creates confusing incidents: a peer connection can be connected while the presence subscription is stale, or the roster can say “online” while the camera permission was revoked.
Consider a concrete timeline. At 10:02:11, the patient joins from a phone and receives session version 7. At 10:02:28, the phone enters a low-power state; the heartbeat stops, but two buffered presence events still reach the clinician's browser. At 10:02:44, the patient taps the join link again, receives version 8, and establishes a new media connection. If the fan-out path applies events by arrival time, the old buffered left can erase version 8 and show an empty room. If it applies the session version, the old event is discarded, the snapshot confirms version 8, and the clinician sees the patient once. Logging those three timestamps, plus the token scope and subscription acknowledgement, tells support exactly which boundary failed. It also makes a replayable test case: suspend the tab, delay one event, reconnect, and assert that the newest version wins.
Use a monotonically increasing sessionVersion (or an equivalent server sequence) for each participant. A late left event from version 3 must not evict a fresh session version 4. This tiny ordering rule prevents a common fan-out race when a clinician refreshes the page during a call.
How should reconnect, expiry, and fan-out failures be observed?
Start with a state machine that names the uncomfortable states: connecting, authorized, subscribed, stale, and closed. Emit a transition when the state changes, with room_id, a pseudonymous session_id, and a correlation ID. Never log access tokens, SDP, or patient content.
The read side can query the channel presence snapshot through the documented route:
type PresenceMember = {
session_id: string;
participant_role: "clinician" | "patient" | "observer";
last_seen_at: string;
session_version: number;
};
async function readPresence(channel: string, token: string) {
const encoded = encodeURIComponent(channel);
const response = await fetch(
`https://api.example.invalid/v1/realtime/presence/get/${encoded}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!response.ok) {
throw new Error(`presence read failed: ${response.status}`);
}
return (await response.json()) as { members: PresenceMember[] };
}
The host should call this after a successful resubscription, not only after a page load. Compare the returned session_version values with the local cache and replace the cache when the snapshot is newer. A snapshot is a repair operation; it is not proof that every event was delivered.
Measure three separate times: token issuance to authorization, authorization to subscription, and subscription to first usable snapshot. Then count stale-member age, duplicate event rate, rejected scope checks, and reconcile operations per reconnect. An alert on “zero members” is noisy for an empty waiting room. An alert on a rising stale-member age for active rooms is actionable.
One short rule: page the on-call on stale data, not on silence.
Logs beat guesses.
A copyable reconciliation loop
The client needs a stable identity and an explicit backoff. Jitter avoids a thundering herd when a regional network blip reconnects thousands of rooms at once. The server still remains the authority; this loop only decides when to ask again.
type LocalMember = PresenceMember & { observedAt: number };
const members = new Map<string, LocalMember>();
const STALE_AFTER_MS = 15_000;
async function reconcile(channel: string, token: string) {
const snapshot = await readPresence(channel, token);
const now = Date.now();
for (const member of snapshot.members) {
const previous = members.get(member.session_id);
if (!previous || member.session_version >= previous.session_version) {
members.set(member.session_id, { ...member, observedAt: now });
}
}
for (const [sessionId, member] of members) {
if (now - member.observedAt > STALE_AFTER_MS) members.delete(sessionId);
}
}
async function reconnect(channel: string, token: string) {
for (let attempt = 0; ; attempt += 1) {
try {
await authorizeAndSubscribe(channel, token);
await reconcile(channel, token);
return;
} catch (error) {
const base = Math.min(30_000, 500 * 2 ** attempt);
const delay = base / 2 + Math.random() * base / 2;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
In production, authorizeAndSubscribe is your existing signaling client, and its telemetry should carry the same correlation ID as reconcile. The example deliberately does not infer presence from WebRTC connectionState; those states answer different questions. The W3C WebRTC Recommendation defines peer-connection behavior, but it does not define your room membership policy.
I once assumed a reconnect was a single event. It isn't. DNS, token refresh, subscription acknowledgement, and snapshot fetch can fail independently. Recording each boundary turned a vague “the doctor disappeared” report into a 401 scope rejection followed by a 22-second stale window. Your mileage may vary, but the decomposition is portable.
Which failure policies fit patient-facing rooms?
The right policy depends on the harm caused by a false state. A stale “online” badge can make a patient wait alone; a false “offline” badge can trigger an unnecessary escalation. Pick an explicit grace period with clinical and support teams, then test it under packet loss, duplicate delivery, clock skew, and browser suspension.
| Failure | Safe room behavior | Signal to collect |
|---|---|---|
| Token expires during a call | Freeze presence, refresh authorization, then reconcile | expiry-to-recovery duration |
| Subscription acknowledgement is lost | Keep media status separate; retry subscription with jitter | retry count and age |
| Duplicate or late event | Compare stable session ID and version; ignore older state | discarded-event counter |
| Snapshot read is unavailable | Show “status updating,” retain the last timestamp, avoid a definitive offline claim | snapshot error rate |
| Browser sleeps | Mark the session stale after the grace period; require a fresh heartbeat on resume | stale-member age |
Do not silently turn a failed authorization into an anonymous public subscription. That makes debugging easier for a moment and security harder forever. Return a stable error class to the UI, record the reason server-side, and ask the user to re-enter the room when the visit capability is no longer valid.
Where is this approach a poor fit?
The catch is that presence is eventually consistent. It is unsuitable for a hard admission decision, such as releasing a regulated recording because the roster appears empty. Use an authoritative transaction or an explicit clinician action for that boundary. Use a presence snapshot for UI hints, handoff prompts, and “who is here” indicators.
It also adds moving parts: token lifecycle, a reaper for stale sessions, sequence handling, and dashboards. A two-person internal prototype can start with an in-memory roster and a clearly documented expiry rule. Once rooms cross browser tabs, mobile sleep cycles, or support-shift handoffs, the extra observability pays for itself. I'm not sure where your room volume makes that trade flip; replay a week of reconnect traces and choose from evidence.
That boundary is deliberate.
The practical decision rule is compact: authorize narrowly, observe each boundary, expire aggressively enough to protect users, and reconcile from a server snapshot after every reconnect. That gives a secure public channel a truthful failure mode without pretending realtime delivery is perfect.
Top comments (0)