Short answer: for reliable realtime room teardown, design the team presence sidebar around an explicit state machine, a monotonic cursor, and bounded backfill rather than treating disconnects as proof that cleanup happened.

I run a one-person SaaS, so my test for infrastructure is revenue per hour. A presence sidebar that shows ghost members creates support work, but a full event platform can create a different kind of drag. The useful design is the smallest one that makes reconnect and backfill deterministic.

Approach Reconnect behavior Backfill responsibility Good fit
Broadcast-only WebSocket Client guesses what it missed Application must reconstruct state Tiny, disposable rooms
Cursor-based event log Client resumes from a sequence Server replays a bounded window Team presence and room membership
WebRTC data channel Peer-dependent; signaling still matters Usually application-defined Media-adjacent metadata, not authority

My default is the middle row. It keeps the authority on the server and lets the UI stay boring. Boring is good when I need to ship weekly.

What should a realtime room teardown guarantee for a presence sidebar?

Teardown should be idempotent. A room can receive leave, disconnect, a token expiry, and an admin close in a different order on different machines. Each signal must converge on the same state without deleting a newer membership record.

Give every room event a sequence number scoped to the room. A client stores the last applied sequence, not just the last timestamp. On reconnect it sends that cursor. If the server still has the range, it replays events; if the cursor fell outside retention, it sends a snapshot followed by new events. This is the key difference between “the socket is open” and “the sidebar is correct.”

The teardown record also needs an owner scope. In a fintech collaboration room, a token for one team must not let a user remove another team's member. Scope the command by tenant, room, and member id, then check authorization before publishing the resulting event. A successful command emits member.removed; it does not ask each browser to infer removal from a transport error.

One small rule saves hours: never use wall-clock order to resolve membership. Clocks drift. Compare the room sequence and a per-member generation instead.

How can Node.js implement reconnect and backfill without ghost members?

The following TypeScript sketch is deliberately transport-neutral. The store could be Postgres, Redis Streams, or a self-hosted log. The contract is what matters: append once, replay from a cursor, and make remove safe to repeat.

type PresenceEvent = { roomId: string; seq: number; kind: "member.joined" | "member.removed" | "room.closed"; memberId?: string; generation?: number; }; interface RoomLog { append(event: Omit<PresenceEvent, "seq">): Promise<PresenceEvent>; after(roomId: string, seq: number): Promise<PresenceEvent[]>; snapshot(roomId: string): Promise<{ members: string[]; seq: number }>; } async function teardownMember( log: RoomLog, roomId: string, memberId: string, generation: number, ) { return log.append({ roomId, kind: "member.removed", memberId, generation, }); } async function resume(log: RoomLog, roomId: string, cursor: number) { const events = await log.after(roomId, cursor); if (events.length > 0 || cursor === 0) return { mode: "events" as const, events }; return { mode: "snapshot" as const, state: await log.snapshot(roomId) }; } 

In production, append must enforce a unique command key or transaction boundary. Otherwise two disconnect handlers can publish two removals. The consumer should also discard an event whose generation is older than the member row it already has. That check makes retries harmless.

The UI should render a connection state separately from member state. “Reconnecting” is honest; clearing the sidebar is not. Keep the last known members visible, mark them stale if your product needs that signal, and replace them only after replay or snapshot succeeds.

Where do common teardown designs fail?

The first failure is treating a close frame as proof that a member left. Mobile networks disappear without a close frame, and a browser can reconnect before the old connection's cleanup job runs. A lease with a short heartbeat can detect this, but lease expiry still has to produce an ordered room event. In one realistic race, connection A starts a cleanup task, connection B renews the same member, and the delayed task finally writes its removal. If the write only checks memberId, the sidebar loses an active person; if it checks the generation captured by A, the stale removal is rejected and the newer presence remains. That comparison belongs in the transaction, beside the append, because moving it into a worker merely moves the race to another queue.

Keep it explicit.

The second failure is unbounded replay. Keeping every event forever sounds safe until a client returns after a week and asks for millions of rows. Set a retention window, measure the snapshot rate, and make the snapshot path a tested feature rather than an emergency branch.

Small rooms still benefit from this boundary.

The third failure is mixing media and presence authority. WebRTC defines peer connection behavior, but it does not define your team's membership database. Libraries such as Socket.IO can simplify transport fallbacks; managed pub/sub services can simplify fan-out; a self-hosted log gives more control over retention. None of these choices removes the need for a cursor and an authorization check.

I once assumed a “leave” message was enough. Then a reconnect raced the cleanup worker and a member briefly vanished, reappeared, and vanished again. The fix was not a longer timeout. It was a generation number checked at write time. Your mileage may vary if your rooms are ephemeral, but the race is still worth testing.

When is this design the wrong trade?

The catch is operational weight. If a room lasts thirty seconds and losing one presence update has no consequence, a broadcast-only channel is cheaper to reason about. Use the simpler design when there is no audit need, no reconnect expectation, and no cross-device session.

Choose a managed realtime service when operating retention, fan-out, and regional failover would slow feature delivery more than it improves your product. Choose a peer-oriented WebRTC channel when the data is truly incidental to an active call and the server does not need an authoritative roster. Stick with a durable, cursor-based log for financial workflows, moderation, billing roles, or any sidebar that drives a permission decision.

My decision rule is practical: outsource undifferentiated delivery, keep the state transition you would debug at 2 a.m. in code you can inspect. That keeps the surface small without pretending disconnects are reliable messages.

References