DEV Community

JorisRhodes8286
JorisRhodes8286

Posted on

Participant Lists in Live Calls — 4 Rules to Clean Empty Rooms

Keep room membership as a server-owned lease, not as a side effect of an open socket. For a live-shopping video call, four invariants are enough to guide the design: one logical participant can own only one current session generation; stale disconnects cannot remove a newer generation; a room disappears only after its last lease expires; and every participant snapshot carries a revision. This produces accurate lists through reconnects without promising that a transient transport is perfectly reliable.

Short answer: expose a snapshot-plus-revision read path, issue a new generation on every successful reconnect, renew membership with bounded leases, and delay empty-room deletion until the final lease is unquestionably expired. Typing indicators may be lossy. Read receipts and membership transitions need identity, ordering, and idempotency.

That distinction matters in commerce. A missed "typing" event is cosmetic; an agent who vanishes from a buyer's call roster, or a receipt attributed to an earlier session, changes what people believe happened. Treating all three signals alike makes the most important path no safer and makes the least important path needlessly expensive.

How should Node.js list participants and clean empty rooms?

WebRTC defines peer connections and the media negotiation surface, but the application still owns signaling and participant presence. A connected peer connection is therefore evidence about media transport, not the canonical room roster. Browsers suspend, networks change, tabs duplicate, and reconnect attempts can overlap. The roster needs its own model. Use stable application identities for people and opaque identifiers for connection attempts. A useful membership record contains room_id, participant_id, generation, lease_deadline, and a small amount of display data. Keep authorization outside that display data: being listed in a room must never grant permission to join it. This model does add periodic writes and a sweeper, so it is not suitable for a disposable demo whose entire state may vanish with one process. That is a deliberate trade-off: production recovery is worth the machinery; a local prototype may reasonably skip it.

The generation is the sharp edge. Suppose generation 7 loses its network path, then generation 8 authenticates and joins. The delayed close callback for generation 7 may arrive afterward. If cleanup means "delete this participant," the valid generation 8 membership disappears. Cleanup must mean "delete this participant only if the stored generation is still 7."

One stale callback is enough.

The room also owns a monotonically increasing revision. Increment it for visible membership changes, not for every heartbeat. A list response can then return a coherent snapshot with revision 42, while a subscription continues from changes after 42. If the consumer sees a revision gap, it discards local assumptions and fetches another snapshot.

Name the failure boundaries before choosing storage

The delivery guarantee is bounded by the state transition, not by the fan-out mechanism. Once a join or leave is committed, notifications may be duplicated, delayed, or missed during a disconnect. Consumers must be able to recover from the durable snapshot. Exactly-once delivery across a database, a process, and a browser is not the invariant here; idempotent application and a repair path are.

State or signal Required behavior Recovery rule Retention
Participant membership Durable within its lease; ordered by room revision Fetch a snapshot after a gap Until explicit leave or lease expiry
Read receipt Idempotent and monotonic per message and reader Re-send the highest acknowledged position Product and compliance policy
Typing indicator Best effort and short lived Let it expire; do not replay it Seconds, in memory
Empty-room marker Conditional on no live membership Recheck after a grace interval Until cleanup completes

This table prevents a common overcorrection: persisting every keystroke-like signal because membership needs durability. A typing indicator should carry an expiry time and disappear without a compensating event. Replaying "agent is typing" after a reconnect is worse than dropping it.

Read receipts sit on the other side of the line. Model them as progress, such as the highest message sequence a participant has read, so retries are naturally idempotent. Do not model them as a growing bag of socket events. Compliance also enters here: receipt retention, access, and deletion should follow the message policy rather than the lifetime of an in-memory room.

The critical path is a compare-and-delete state machine

The following Python models the storage contract behind an HTTP framework and a realtime transport. It deliberately leaves database syntax out: the important operations are conditional writes that a transaction, compare-and-swap primitive, or server-side script can enforce atomically.

from dataclasses import dataclass, field
from time import monotonic
from uuid import uuid4


@dataclass
class Member:
    participant_id: str
    generation: str
    lease_deadline: float


@dataclass
class Room:
    revision: int = 0
    members: dict[str, Member] = field(default_factory=dict)
    empty_since: float | None = None


class PresenceStore:
    def __init__(self, lease_seconds: float = 30.0, empty_grace: float = 15.0):
        self.rooms: dict[str, Room] = {}
        self.lease_seconds = lease_seconds
        self.empty_grace = empty_grace

    def join(self, room_id: str, participant_id: str) -> tuple[str, int]:
        now = monotonic()
        room = self.rooms.setdefault(room_id, Room())
        generation = uuid4().hex
        room.members[participant_id] = Member(
            participant_id=participant_id,
            generation=generation,
            lease_deadline=now + self.lease_seconds,
        )
        room.empty_since = None
        room.revision += 1
        return generation, room.revision

    def renew(self, room_id: str, participant_id: str, generation: str) -> bool:
        room = self.rooms.get(room_id)
        member = room and room.members.get(participant_id)
        if member is None or member.generation != generation:
            return False
        member.lease_deadline = monotonic() + self.lease_seconds
        return True

    def leave(self, room_id: str, participant_id: str, generation: str) -> bool:
        room = self.rooms.get(room_id)
        member = room and room.members.get(participant_id)
        if member is None or member.generation != generation:
            return False
        del room.members[participant_id]
        room.revision += 1
        if not room.members:
            room.empty_since = monotonic()
        return True

    def snapshot(self, room_id: str) -> dict:
        room = self.rooms.get(room_id, Room())
        return {
            "revision": room.revision,
            "participants": sorted(room.members),
        }

    def sweep(self) -> list[str]:
        now = monotonic()
        deleted = []
        for room_id, room in list(self.rooms.items()):
            expired = [
                participant_id
                for participant_id, member in room.members.items()
                if member.lease_deadline <= now
            ]
            for participant_id in expired:
                del room.members[participant_id]
                room.revision += 1
            if not room.members and room.empty_since is None:
                room.empty_since = now
            if (
                not room.members
                and room.empty_since is not None
                and now - room.empty_since >= self.empty_grace
            ):
                del self.rooms[room_id]
                deleted.append(room_id)
        return deleted
Enter fullscreen mode Exit fullscreen mode

The sample is single-process so the contract stays visible. In production, join, renew, leave, revision allocation, and the final empty check must use storage operations with equivalent atomicity. A process-local dictionary is correct only when one process owns every event for a room and restart loss is acceptable.

An Express application can expose the same contract through authenticated join, renew, leave, and snapshot handlers, while its socket layer publishes revisioned changes. The exact framework callback is secondary. Authenticate the participant before accepting participant_id; derive room access from server-side policy; and never trust a client-supplied generation until it has been matched to that participant's current stored record.

Fan-out follows commit. Publish a compact event containing the room, revision, operation, and participant identity after the state change succeeds. Duplicate events are harmless when clients ignore revisions they have already applied. A gap is actionable: fetch the list again. If publication can fail independently after commit, use an outbox record in the same transaction as the membership mutation, then let a worker retry publication.

How should reconnect races be tested?

Start with reordered operations, because happy-path integration tests rarely expose presence errors. Join generation A, join generation B for the same participant, then deliver A's close callback. B must remain. Next, expire A immediately before its renewal is processed; the renewal must either win atomically or receive a rejection that forces a fresh join. An ambiguous half-renewed state is unacceptable.

Test two participants leaving on different workers. Only the worker observing the atomic transition to zero may schedule cleanup, and the cleanup job must recheck emptiness before deletion. Then join a new participant during the grace interval. The room and its new revision must survive. There are more mundane cases too: duplicated leave messages, a heartbeat received after explicit leave, two browser tabs sharing one user identity, and a consumer reconnecting with an old revision. Put those permutations in a deterministic state-machine test. Reserve browser tests for signaling and UI behavior; they are slower and worse at exploring reorderings. Operationally, graph lease expirations separately from explicit leaves. A rising expiration ratio often points to transport trouble, suspended clients, or overloaded event loops. Track snapshot repairs caused by revision gaps, cleanup lag, current members per room, and rejected stale generations. Avoid participant identifiers in high-cardinality metric labels. Structured logs can carry a pseudonymous identifier under an access and retention policy. During a rolling restart, old processes may emit late disconnect callbacks after new processes have accepted reconnects, which is precisely why generation checks belong in shared state. A sweeper should be safe to run concurrently, and its work should be partitioned or claimed so a large room set does not create a synchronized scan spike.

Measure the boundary.

Rejected option: delete on the last socket close

Deleting a room as soon as the last locally known socket closes is attractive because it is short, cheap, and easy to explain. It is also the wrong default for horizontally scaled video-call presence. The closing process may not know about a connection accepted elsewhere, and a reconnect can race the deletion.

The option remains valid for an ephemeral, single-process prototype where rooms contain no durable state, all connections are owned by that process, and restart loss is explicitly acceptable. State those boundaries in the decision record. Once recordings, receipts, moderation state, or multiple workers enter the design, socket lifetime and room lifetime have separated.

The practical rule is compact: a disconnect is a hint; an expired, generation-matched lease is evidence. Make snapshots authoritative, make fan-out repairable, and make deletion conditional. Those choices keep participant lists believable during the exact moment a buyer or support agent is already dealing with a shaky call.

References

Top comments (0)