For a Node.js video-call service, I keep the participant list on the server, clean up empty rooms from the same expiry data, and treat a disconnect as a lease that must expire. In a media editor that also hosts calls, presence accuracy is the deciding constraint: a stale cursor or a ghost caller is more damaging than a room that disappears a few seconds late.
Short answer: keep one authoritative membership record per room, attach a monotonically increasing connection epoch to every join, and remove a member only when its heartbeat lease expires or its current epoch closes. The list endpoint reads that record. Cleanup runs from the same expiry data, so listing and deletion cannot disagree.
How can a Node.js list keep participants and clean empty rooms?
A participant is a logical person, not a TCP socket. One person can open a second tab, switch networks, or reconnect while the old connection is still visible to the process. If the room stores sockets as members, a reconnect briefly creates two users or, worse, the old socket removes the new one.
Race first.
I use a room map with a record like this:
type Member = { userId: string; epoch: number; lastSeenMs: number; cursor?: { line: number; column: number }; }; type Room = Map<string, Member>; // key: userId The userId key makes the public participant list stable. The epoch makes ownership explicit. A join increments the epoch and returns it to the client. Every heartbeat, cursor update, and leave request carries that value. A message with an older epoch is ignored. This small check prevents a delayed packet from an old Wi-Fi session from deleting a current presence entry.
For an editor, the cursor payload is just another presence field. I do not put document contents in this room record. The room answers who is present and where their pointer was last seen; the document service owns edits and conflict resolution. That boundary keeps a large paste from delaying a heartbeat.
How do reconnects avoid ghost members?
The reconnect path has three distinct cases. A clean close removes the member immediately when the epoch matches. A dropped network leaves the member until its lease expires. A reconnect starts a new epoch and overwrites the member record, so the old lease can no longer win a race. I first assumed a socket close callback was enough; reconnect timing disproves that assumption because the callback can arrive after the new session is already live.
Here is a compact Express implementation using an in-memory store for one process. It is intentionally boring. A single-process prototype is easy to reason about; a multi-process deployment needs the same compare-and-delete operation in a shared store.
import express from "express"; const app = express(); app.use(express.json()); const LEASE_MS = 15_000; const rooms = new Map<string, Map<string, Member>>(); type Member = { userId: string; epoch: number; lastSeenMs: number; }; function getRoom(roomId: string) { let room = rooms.get(roomId); if (!room) { room = new Map(); rooms.set(roomId, room); } return room; } function prune(roomId: string, now = Date.now()) { const room = rooms.get(roomId); if (!room) return; for (const [userId, member] of room) { if (now - member.lastSeenMs > LEASE_MS) room.delete(userId); } if (room.size === 0) rooms.delete(roomId); } app.post("/rooms/:roomId/join", (req, res) => { const { roomId } = req.params; const { userId } = req.body as { userId?: string }; if (!userId) return res.status(400).json({ error: "userId is required" }); prune(roomId); const room = getRoom(roomId); const previous = room.get(userId); const epoch = (previous?.epoch ?? 0) + 1; room.set(userId, { userId, epoch, lastSeenMs: Date.now() }); return res.json({ roomId, userId, epoch }); }); app.get("/rooms/:roomId/participants", (req, res) => { const { roomId } = req.params; prune(roomId); const room = rooms.get(roomId); return res.json({ roomId, participants: room ? [...room.values()].map(({ userId, epoch }) => ({ userId, epoch })) : [] }); }); app.post("/rooms/:roomId/heartbeat", (req, res) => { const { roomId } = req.params; const { userId, epoch } = req.body as { userId?: string; epoch?: number }; const member = rooms.get(roomId)?.get(userId ?? ""); if (!member || member.epoch !== epoch) return res.status(409).json({ error: "stale session" }); member.lastSeenMs = Date.now(); return res.sendStatus(204); }); app.post("/rooms/:roomId/leave", (req, res) => { const { roomId } = req.params; const { userId, epoch } = req.body as { userId?: string; epoch?: number }; const room = rooms.get(roomId); const member = room?.get(userId ?? ""); if (room && member?.epoch === epoch) room.delete(userId as string); if (room && room.size === 0) rooms.delete(roomId); return res.sendStatus(204); }); setInterval(() => { for (const roomId of rooms.keys()) prune(roomId); }, 5_000).unref(); app.listen(3000); The endpoint returns 409 for a stale heartbeat instead of silently reviving an old session. The client then performs a fresh join and replaces its local epoch. That response is useful telemetry: it tells me a reconnect raced with an in-flight request, not that the user was rejected.
A browser heartbeat can run every 5 seconds with a 15-second lease. Those values are policy, not a protocol requirement. Pick them from observed mobile suspend times and the tolerance of your UI. A cursor that lingers for 15 seconds may be acceptable; a regulated call roster may need a shorter bound and an explicit “reconnecting” state.
The limitation is clear: an in-memory map is unsuitable once more than one process can receive traffic or when a restart must preserve membership. That trade-off is fine for a weekly shipping loop, but the shared-store migration is part of the scale plan, not an optional cleanup task.
When should an empty room be deleted?
Delete an empty room after pruning, and make pruning happen before every read and write that exposes membership. That ordering matters. If cleanup is only a background job, the list endpoint can return a room that the cleaner deletes a moment later. If cleanup is only request-driven, an abandoned room can remain forever when nobody asks for it again. The interval above handles the second case; request-time pruning handles the first.
In a single process, Map operations are atomic enough because JavaScript runs each callback to completion. Across processes, put the room and member records in a store that supports a conditional update or transaction. The condition must include both userId and epoch; checking only the user ID recreates the old-session race at a larger scale. Expiry notifications are helpful, but they are not the source of truth. Re-read the record before deleting it.
I also attach a room-generation value when creating a room. If a room is deleted and quickly recreated, logs can distinguish the two lifetimes. This has saved me from misreading a healthy recreate as a duplicate-room bug during deploys.
What do I test before shipping this weekly?
The highest-value tests are races, not happy-path HTTP snapshots. I use fake time and drive these sequences:
- Join
aliceat epoch 1, reconnect at epoch 2, then deliver the epoch-1 leave. Alice must remain. - Stop heartbeats, advance the clock beyond 15 seconds, list participants, and verify both the empty list and room deletion.
- Join two users, let one expire, and confirm the other user's epoch and cursor are unchanged.
- Send a heartbeat after the room has been deleted and require a
409, followed by a new join.
The production signals are equally concrete: active rooms, members pruned by expiry, stale-epoch conflicts, heartbeat latency, and the age of the oldest lastSeenMs. Alert on a rising stale-epoch rate or a lease age that approaches the lease limit. Those metrics describe presence accuracy directly, while raw connection counts often hide the duplicate-session problem.
At scale, I would move room membership to a shared store, make the epoch increment atomic, and add a pub/sub channel for participant changes. I would keep the HTTP contract and tests unchanged. That is the useful trade: outsource undifferentiated connection bookkeeping to a proven storage primitive, while keeping the decision rule in application code where the media workflow can see it.
The implementation is small because the invariant is small: one logical member per user, one current epoch, and one expiry rule. Keep those three facts together and reconnect handling stops being a collection of socket callbacks.
