Do not destroy a video room on the last leave event. Mark it empty, wait through a bounded grace period, and then require a second, authoritative occupancy check before teardown. For a fintech support call that also exposes typing indicators and read receipts, this rule keeps presence accurate during reconnects while ensuring abandoned sessions cannot accumulate forever.
TL;DR: Treat room deletion as a state transition, not an event callback. A leave signal starts suspicion; a later observation proves absence. Make teardown idempotent, retain a periodic reconciler as the repair path, and measure how long rooms remain empty rather than counting successful callbacks.
That distinction matters because WebRTC describes peer connections, not the application-level membership of a room. The W3C API exposes connection state and a way to close an RTCPeerConnection, but it does not appoint the browser, signaling node, or media layer as the owner of a shared room record. The application has to define that ownership boundary.
Why does the last participant keep leaving a room behind?
The usual bug begins with a reasonable shortcut: decrement an in-memory counter when a socket disconnects, and delete the room when the counter reaches zero. That counter is only a local projection. A tab can reconnect to another process, two disconnect notifications can be delivered in an awkward order, a process can stop before its cleanup callback runs, or a delayed heartbeat can make an absent participant look current. None of those observations alone is a durable statement about room occupancy.
Read receipts and typing indicators make the trap easier to see. They are ephemeral signals associated with a participant, but they have different expiry rules. A typing flag should disappear quickly; a read position may need to remain as business data; a WebRTC peer connection can be closed while the authenticated support session remains eligible to reconnect. Folding all three into one boolean named online creates a cleanup system whose result depends on whichever signal happened to arrive last.
So separate them. Maintain durable membership identity, expiring presence leases, and disposable connection records. Derive occupied from unexpired leases stored in the authoritative presence system, not from the number of connections known to one signaling process. This is the capacity-planning reflex worth keeping: if the correctness of cleanup changes when the service grows from one node to two, the state was in the wrong place.
No proof, no deletion.
Use a monotonic empty-room state machine
The safe path has four useful states: active, suspected empty, confirmed empty, and deleted. A participant departure may move a room from active to suspected empty and record a generation plus a deadline. Any valid presence renewal returns it to active and advances the generation. Once the deadline passes, a worker reads current leases; only zero live leases for the same generation permits deletion.
Short callbacks help. The request or socket handler should record intent and return, because media cleanup, transcript finalization, and audit retention do not belong on its latency path.
The following Go sketch deliberately uses interfaces rather than a database-specific transaction API. The important constraints are the compare-and-set generation check and an idempotent delete operation.
package cleanup
import (
"context"
"errors"
"time"
)
var ErrStateChanged = errors.New("room state changed")
type Room struct {
ID string
Generation uint64
CleanupAfter time.Time
State string
}
type Store interface {
MarkSuspectedEmpty(ctx context.Context, roomID string, now, cleanupAfter time.Time) (Room, error)
CountLiveLeases(ctx context.Context, roomID string, at time.Time) (int, error)
DeleteIfUnchanged(ctx context.Context, roomID string, generation uint64) error
}
type Cleaner struct {
Store Store
Grace time.Duration
}
func (c Cleaner) OnLastObservedDeparture(ctx context.Context, roomID string, now time.Time) error {
_, err := c.Store.MarkSuspectedEmpty(ctx, roomID, now, now.Add(c.Grace))
return err
}
func (c Cleaner) ConfirmAndDelete(ctx context.Context, room Room, now time.Time) error {
if room.State != "suspected_empty" || now.Before(room.CleanupAfter) {
return ErrStateChanged
}
live, err := c.Store.CountLiveLeases(ctx, room.ID, now)
if err != nil {
return err
}
if live != 0 {
return ErrStateChanged
}
return c.Store.DeleteIfUnchanged(ctx, room.ID, room.Generation)
}
The generation is doing real work. Suppose a cleanup job reads generation 41, then a support agent reconnects and advances the room to generation 42. The stale job has already passed its deadline check, and it may even have read zero live leases a moment before the renewal committed; without one final conditional write, it can delete the room that the agent has just rejoined. The compare-and-set makes that race lose safely because generation 41 is no longer current. Retrying the same job must also be harmless: an already deleted room is success, while a changed generation is a no-op rather than an error that wakes the on-call engineer. This is the concrete pitfall behind the design, and a mutex inside one process cannot fix it because the reconnect and the cleanup may run on different nodes.
Do not guess the grace interval from taste. Set it from the reconnect behavior the product promises, the presence lease duration, and the cleanup-lag SLO. If clients may legitimately renew a lease until its expiry, the confirmation read has to occur after that evidence can be evaluated. A fixed number copied from another system is not evidence.
Make reconciliation part of the normal path
Event-driven cleanup lowers delay, but it cannot be the only route to correctness. A periodic reconciler should scan rooms whose cleanup deadline has passed, claim a bounded batch, and execute the same confirmation function. This is not a second deletion policy. It is another trigger for the same transition.
Keep the scan bounded by both rows and time so a backlog does not monopolize the datastore. Persist a cursor or use an indexed deadline range, limit worker concurrency, and apply backoff when the authoritative presence store is unavailable. Failure to confirm absence must preserve the room. Fail closed.
That choice has a cost.
Capacity planning starts with arrival and service rates. Track the number of rooms entering suspected-empty state per minute, the rate at which workers confirm them, and the age of the oldest eligible room. If arrivals exceed completions for a sustained window, more callbacks will not rescue the queue; either worker capacity, datastore capacity, or the confirmation dependency is saturated. The oldest age is the user-relevant symptom.
For a first operating envelope, define two explicit objectives rather than hiding behind an average:
- No active lease is removed by automated teardown.
- A chosen high percentile of rooms with zero live leases is deleted within the cleanup-lag target.
The first is a safety invariant. The second is a timeliness SLO whose threshold must come from retention, capacity, and product requirements. They need separate alerts because the mitigations point in opposite directions: aggressive deletion improves lag and increases safety risk, while conservative deletion protects reconnects and consumes more resources.
Verify the race, not merely the happy path
A unit test that calls leave and observes delete proves very little. Drive the state machine with a controllable clock and exercise reordered operations: renewal before the deadline, renewal between confirmation and deletion, duplicate departure, duplicate cleanup, worker restart, and an unavailable presence store. The expected outcome is boring: active generations survive, stale jobs do nothing, and eligible empty generations eventually disappear.
Then test across two signaling processes. One process should observe the apparent last departure while the other accepts a reconnect. If the room vanishes, the implementation still relies on local knowledge somewhere. This single test catches a surprising amount of accidental in-memory authority.
Instrument transitions, not participant identifiers. Useful low-cardinality dimensions include transition result, worker shard, and failure class. Useful values include suspected-room count, eligible-room count, cleanup lag, generation-conflict count, lease-check failures, and idempotent-delete outcomes. Room IDs belong in sampled logs or traces under the organization's data-handling rules; they do not belong in metric labels.
Avoid using WebRTC's connectionState as the deletion oracle. Per the specification, it is an aggregate state of an individual RTCPeerConnection. It can help diagnose transport progress for that peer, and calling close() terminates that peer connection, but neither action proves that no other participant lease exists for the application room.
Buy or build the control plane?
This decision is narrower than the usual platform debate. Managed signaling, a hosted presence service, or a self-hosted stack can all support correct cleanup if they expose authoritative membership, conditional state changes, expiry semantics, and enough observability to verify the SLO. Evaluate the control-plane contract, not the logo.
| Decision surface | Managed service | Self-hosted control plane | Acceptance test |
|---|---|---|---|
| Presence authority | Contract and limits are externally defined | Team owns storage and lease semantics | Two-node reconnect cannot be deleted by a stale job |
| Conditional deletion | May be an API primitive or require application state | Can be implemented in the chosen datastore | Old generation cannot delete a renewed room |
| Reconciliation | May be built in, observable, or application-owned | Team schedules and operates workers | Missed departure is repaired within the SLO |
| On-call load | Less infrastructure, but dependency behavior remains | More control and more failure modes to cover | Dependency loss preserves active rooms and raises a useful alert |
| Portability | Depends on event and state-model coupling | Depends on internal schema and operations | Cleanup logic runs behind a small generic interface |
I would reject either option if it only promises a departure webhook without a read-after-delay occupancy check. I would also reject a design whose delete cannot be conditioned on version or generation. Those two gaps turn ordinary delivery races into data-loss decisions.
The limitation of this method is delayed reclamation: the grace period intentionally retains an empty room for longer, and the reconciler adds datastore reads plus an operated queue. It is not appropriate for a purely local, single-user preview whose entire state can disappear with the process, because there is no shared room to protect; direct teardown is simpler there. At the other extreme, if regulation requires a durable call record after media ends, delete only ephemeral transport and presence state, then hand the retained record to the separately governed retention workflow. The trade-off is explicit: a longer grace period tolerates more reconnect delay but increases temporary room inventory, while a shorter one reclaims capacity sooner but demands tighter lease timing.
Roll out with a kill switch and a drain plan
Start in observation mode: compute which rooms would be deleted, record the generation and evidence, but do not execute teardown. Compare those candidates with live lease data and with later reconnects. Only after the false-positive count is demonstrably zero over a representative operating window should automated deletion begin, first for a small deterministic slice of rooms.
Rollback must be immediate. A runtime kill switch should stop new destructive actions without stopping lease expiry, candidate marking, or reconciliation telemetry. If the safety invariant is threatened, disable deletion and retain evidence for analysis; do not attempt to recreate a removed live session from logs.
The runbook needs three concrete branches: rising cleanup lag with healthy lease reads, failed lease reads, and generation conflicts. The first calls for inspecting worker and datastore capacity. The second pauses deletion because absence cannot be proven. The third may be normal reconnect activity, but a sharp change warrants checking client retry behavior and event ordering before increasing capacity.
The final decision rule stays small: suspect on an event, prove from current state, delete conditionally, and reconcile until the lag objective is met. Empty rooms then become a measurable lifecycle, while typing indicators, read receipts, and reconnecting calls retain the distinct semantics they require.
References
- W3C, WebRTC 1.0: Real-Time Communication Between Browsers: https://www.w3.org/TR/webrtc/
Top comments (0)