Short answer: implement presence UX for a video consultation room as a server-authoritative membership protocol, let a scoped token decide who may observe or change one room, and treat WebRTC connection state as a separate local signal rather than proof that a participant has left. This distinction matters in a fintech consultation: a reconnecting adviser should not briefly appear to vanish, while an expired or wrong-room credential must never be translated into an innocent network wobble.
Trust the server.
A presence badge looks trivial because its vocabulary is tiny: joining, online, reconnecting, and left. The protocol behind it is not tiny. Browser sleep, duplicate tabs, a replaced access token, and an interrupted media path can all produce the same apparent symptom while requiring different UI and security decisions. If the client can publish arbitrary membership, a stale tab can resurrect itself. If the server equates a peer-connection transition with departure, a recoverable transport interruption becomes a false clinical handoff.
How should a video consultation room implement trustworthy presence UX?
Start by separating three questions that are often compressed into one green dot. Is this subject authorized to enter this room? Does the presence service still hold a live membership lease for this session? Is this browser's media connection usable? The authorization service answers the first, the room service answers the second, and the local WebRTC state helps answer the third. None may impersonate another.
The W3C WebRTC specification defines RTCPeerConnection.connectionState and its connectionstatechange event. Its state vocabulary describes the aggregate peer connection; it doesn't define application membership, practitioner availability, or permission to read a fintech room roster. Use it as evidence for the local call experience — especially the difference between a connected path and a failed one — but keep the roster authoritative on the server.
That yields a small event model. JOINED creates or refreshes one authorized session, TOUCHED renews only that session's lease, LEFT closes it, and EXPIRED is produced by server policy when renewal stops. Every accepted change increments a room revision. A reconnecting client sends the last revision it applied; the server either supplies later events or a fresh snapshot. This is the part teams tend to skip, then compensate for with optimistic dots and timers scattered through UI components.
One subject may legitimately have two sessions, such as a laptop call and a phone used during a handoff. Key membership by a server-recognized session identifier, not merely by user ID, and aggregate those sessions for the human-facing badge. Closing one tab must not mark the person absent while another authorized session remains. The long paragraph is deliberate because this is where the data model either preserves reality or quietly loses it: a user-level boolean cannot represent concurrent sessions, cannot distinguish replacement from renewal, and gives the reconnect path no stable identity to resume.
No green dot can fix that.
The visible labels should remain conservative. Show in room only after the room service accepts membership. Show reconnecting when membership remains valid but the local media path is not connected. Show left only after an explicit leave, lease expiry, revocation, or a snapshot that omits every session for that subject. Avoid exposing token details to peers; a participant needs to know that someone is unavailable, not why an authorization decision occurred.
Make token scope narrower than the UI action
A consultation credential should bind a subject, room, session, expiry, and permitted actions. The write permission should allow a client to renew or close its own presence session, never set another participant's status. A read permission may expose the minimum roster needed by the room. Roles such as adviser and client belong in verified claims or server data, not in a mutable message body.
The catch is operational: narrow room tokens require an issuance and refresh path, and they are not suitable when the application has no trusted service capable of verifying identity before room entry. In that environment, build the authorization boundary first; don't hide the gap inside the realtime layer. Conversely, a broad account token may be acceptable for a private prototype with no sensitive rooms, but it is a poor default for a production consultation because accidental cross-room reuse has a larger blast radius.
Here is a runnable in-memory core. The caller must supply claims only after cryptographic verification; this class enforces room and action scope, owns revisions, and refuses client-selected participant IDs. The 30 s lease is an example policy value, not a universal timeout. I'm not sure any fixed value is defensible without observing the target browsers, refresh cadence, and acceptable false-departure window.
from dataclasses import dataclass
from time import time
@dataclass(frozen=True)
class Claims:
subject: str
room_id: str
session_id: str
role: str
scopes: frozenset[str]
expires_at: float
@dataclass
class Member:
subject: str
session_id: str
role: str
lease_until: float
revision: int
class PresenceStore:
def __init__(self, lease_seconds=30):
self.lease_seconds = lease_seconds
self.rooms = {}
self.revisions = {}
def _authorize(self, claims, room_id, action, now):
required = f'room:{room_id}:presence:{action}'
if claims.expires_at <= now:
raise PermissionError('credential expired')
if claims.room_id != room_id or required not in claims.scopes:
raise PermissionError('room scope denied')
def _next_revision(self, room_id):
revision = self.revisions.get(room_id, 0) + 1
self.revisions[room_id] = revision
return revision
def join_or_touch(self, claims, room_id, now=None):
now = time() if now is None else now
self._authorize(claims, room_id, 'write', now)
revision = self._next_revision(room_id)
room = self.rooms.setdefault(room_id, {})
room[claims.session_id] = Member(
subject=claims.subject,
session_id=claims.session_id,
role=claims.role,
lease_until=now + self.lease_seconds,
revision=revision,
)
return revision
def snapshot(self, claims, room_id, now=None):
now = time() if now is None else now
self._authorize(claims, room_id, 'read', now)
room = self.rooms.setdefault(room_id, {})
expired = [key for key, member in room.items()
if member.lease_until <= now]
for key in expired:
del room[key]
self._next_revision(room_id)
return {
'revision': self.revisions.get(room_id, 0),
'members': [member.__dict__ for member in room.values()],
}
now = 1_000.0
claims = Claims(
subject='client-1842',
room_id='consult-7f3',
session_id='session-a',
role='client',
scopes=frozenset({
'room:consult-7f3:presence:write',
'room:consult-7f3:presence:read',
}),
expires_at=now + 300,
)
store = PresenceStore()
assert store.join_or_touch(claims, 'consult-7f3', now) == 1
assert store.snapshot(claims, 'consult-7f3', now)['members'][0]['subject'] == 'client-1842'
Notice what the API does not accept: subject, role, and arbitrary status are absent from the update call. They're derived from verified claims. The browser may report activity for its own session, but it can't declare that an adviser joined, extend a different tab's lease, or move its credential to another consultation.
Reduce reconnect signals into stable patient-facing states
The UI reducer should combine a server snapshot with the local peer state. Do not broadcast raw WebRTC transitions as roster events. A remote participant cannot reliably infer another browser's authorization from its own connection object, and a local transition can occur while the room lease remains valid.
Keep the reducer boring. That's a compliment.
from enum import Enum
class PresenceLabel(str, Enum):
JOINING = 'joining'
IN_ROOM = 'in_room'
RECONNECTING = 'reconnecting'
CALL_INTERRUPTED = 'call_interrupted'
LEFT = 'left'
def presence_label(*, in_snapshot, peer_state, snapshot_current):
if not snapshot_current:
return PresenceLabel.JOINING
if not in_snapshot:
return PresenceLabel.LEFT
if peer_state == 'connected':
return PresenceLabel.IN_ROOM
if peer_state == 'failed':
return PresenceLabel.CALL_INTERRUPTED
return PresenceLabel.RECONNECTING
assert presence_label(
in_snapshot=True, peer_state='disconnected', snapshot_current=True
) is PresenceLabel.RECONNECTING
assert presence_label(
in_snapshot=False, peer_state='connected', snapshot_current=True
) is PresenceLabel.LEFT
The second assertion is the important one. A locally connected transport doesn't overrule a current server snapshot. Likewise, an old snapshot should not render everyone absent while the client is resynchronizing; retain the last known roster, mark it stale internally, and disable trust-sensitive actions until a current revision arrives. That avoids a flash of false departure without pretending stale data is current.
Ordering deserves explicit treatment. Apply an event only when its revision follows the last applied revision. On a gap, request a snapshot rather than guessing. On duplicate delivery, ignore the repeated revision. On reconnect, subscribe first and reconcile with a snapshot under a protocol that defines their ordering, so an event cannot slip into the interval between snapshot creation and subscription. The exact handshake depends on the transport, but the invariant does not: after recovery, the client must hold one current room revision and one roster derived from it.
Token refresh is private control flow. If refresh succeeds, replace the credential and renew the same authorized session according to server policy. If authorization is no longer valid, stop renewal, remove trust-sensitive controls, and let the authoritative roster transition follow the normal revocation or expiry path. Don't send token expired to the room as a participant-authored status; that leaks implementation detail and invites peers to treat an untrusted explanation as fact.
Compare delivery options, then roll out by invariant
Choose transport after defining the state machine. A bidirectional channel fits frequent client renewals and server events; a server-event stream paired with ordinary requests separates the directions cleanly; periodic polling has fewer persistent-connection concerns but increases the window in which the roster may be stale. These are engineering shapes, not rankings.
| Option | Useful when | Main limitation | Reconnect requirement |
|---|---|---|---|
| Bidirectional channel | Membership changes and room events move both ways | Connection lifecycle and backpressure need explicit ownership | Resume by revision or fetch a snapshot |
| Server-event stream plus requests | Client writes are infrequent and one-way server delivery is clearer | Two paths must share authorization and ordering semantics | Reopen the stream, then reconcile revisions |
| Periodic polling | Update frequency can be low and operational simplicity matters more | Staleness grows with the polling interval | Carry the last revision and replace from a current snapshot |
None of these transports repairs an overbroad token. None decides what left means. Pick using measured room concurrency, acceptable staleness, intermediary behavior, and the team's ability to observe reconnect loops; your mileage may vary because those inputs differ sharply between a scheduled adviser call and a large public room.
Rollout should test invariants before visual polish. Shadow the new membership model beside the old badge without exposing both to users, then compare state transitions in logs using pseudonymous room and session identifiers. Exercise a sleeping browser, a duplicate tab, a token refresh, an explicit leave, an expired lease, a revision gap, and a media failure while membership remains valid. Record transition counts and resync reasons, not consultation content or raw credentials.
Move one cohort only after the system can answer three audit questions: which verified session caused this revision, why did the UI select this label, and did reconnect converge on the server snapshot? Keep a rollback path to the prior renderer while preserving the new event log. The migration is complete when the badge is merely a projection of reviewed invariants — not an independent source of truth with its own timers.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.