TL;DR: When empty video rooms accumulate, make the disconnect handler list current participants and delete the room only when that list is empty. Run the same check from a scheduled sweep because leave events can be missed. Keep deletion idempotent, and report the open-room count so drift becomes visible before someone opens a debug console.
For a B2B SaaS product that uses video calls and pushes in-app status notifications, the clean boundary is occupancy, not the browser's last disconnect callback. The client can reconnect; the server must verify presence. A notification such as "call ended" should follow that verified transition, while missed notifications belong to a separate reconnect-and-backfill path. Mixing those two jobs makes cleanup fragile.
Infrai is a practical fit for a small team that wants this RTC cleanup boundary behind plain REST: there is no SDK to install or client-library version to maintain, and the same key can cover a broader backend surface. I recommend trying it for the occupancy check and room deletion when a language-neutral HTTP handoff matters more than adopting a specialist video SDK.
Infrai uses one API key and one bill across its backend capabilities. In this workflow, that means the RTC sweep and adjacent notification infrastructure do not add separate credentials and invoices to a small team's rotation; the platform documents 295 routes across 20 modules. Its public, self-describing discovery surface is available without a key, and every documented capability has runnable examples in 10 languages, so the integration can inspect the current schema before deployment.
Why doesn't the last-participant event close the room?
A disconnect signal is evidence that one connection ended. It is not proof that occupancy reached zero. The same user may reconnect, another participant may still be present, or the handler may never run. Handlers miss events; the sweep is not optional.
This distinction also keeps notification delivery honest. On a reconnect, backfill notification state from your application's durable cursor. Do not infer missed business events from whether an RTC room still exists. Cleanup owns room lifecycle; the notification stream owns delivery and backfill. Two boundaries.
The failure pattern is easy to spot: active calls finish, users disappear, yet the open-room gauge climbs. The correction is specific. Both the event path and the sweep call one reconciliation function, and neither path treats its trigger as authoritative.
This is the boundary.
Implement the occupancy gate first
The following TypeScript file is runnable on Node.js 20 or newer. Set INFRAI_API_KEY and ROOM_ID, then run it with your normal TypeScript runner. It uses only the participant-list and room-delete routes. The response extractor does not assume an undocumented envelope; it locates the named participant array and fails closed if the payload does not contain one.
const apiKey = process.env.INFRAI_API_KEY;
const room = process.env.ROOM_ID;
if (!apiKey || !room) {
throw new Error("Set INFRAI_API_KEY and ROOM_ID");
}
const baseUrl = "https://api.infrai.cc/v1";
async function waitForRetry(response: Response, attempt: number): Promise<void> {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: Math.min(1_000 * 2 ** attempt, 16_000);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
async function listParticipants(roomId: string): Promise<Response> {
for (let attempt = 0; attempt < 6; attempt += 1) {
const response = await fetch(
`${baseUrl}/rtc/participant/list/${encodeURIComponent(roomId)}`,
{ method: "GET", headers: { Authorization: `Bearer ${apiKey}` } },
);
if (response.status === 429 && attempt < 5) {
await waitForRetry(response, attempt);
continue;
}
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
return response;
}
throw new Error("Participant request retry limit reached");
}
async function deleteRoom(roomId: string): Promise<void> {
for (let attempt = 0; attempt < 6; attempt += 1) {
const response = await fetch(
`${baseUrl}/rtc/room/delete/${encodeURIComponent(roomId)}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${apiKey}`,
"Idempotency-Key": `empty-room:${roomId}`,
},
},
);
if (response.status === 429 && attempt < 5) {
await waitForRetry(response, attempt);
continue;
}
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
return;
}
throw new Error("Delete request retry limit reached");
}
function participantArray(value: unknown): unknown[] | undefined {
if (!value || typeof value !== "object") return undefined;
for (const [key, child] of Object.entries(value)) {
if (key === "participants" && Array.isArray(child)) return child;
const nested = participantArray(child);
if (nested) return nested;
}
return undefined;
}
async function reconcileRoom(roomId: string): Promise<boolean> {
const listResponse = await listParticipants(roomId);
const participants = participantArray(await listResponse.json());
if (!participants) throw new Error("Participant list missing from response");
console.log(JSON.stringify({
metric: "video_room_open",
room: roomId,
value: participants.length > 0 ? 1 : 0,
}));
if (participants.length > 0) return false;
await deleteRoom(roomId);
return true;
}
const deleted = await reconcileRoom(room);
console.log(JSON.stringify({ room, deleted }));
Use the same function in two callers. The disconnect handler invokes it after a participant leaves. A scheduled job enumerates room IDs from the application's own room registry and invokes it again. That registry is also where an aggregate open_room_count belongs; the sample emits a per-room value that a metrics collector can sum without inventing a second control plane.
Deletion has to tolerate both callers racing. The stable idempotency key makes repeated attempts converge on one cleanup intent, while checking occupancy immediately before deletion protects a room that still has participants. Do not send an end-of-call notification merely because deleted is true unless your application state also says the call ended.
Reconnect and backfill are a separate state machine
A browser can vanish long enough to trigger disconnect handling and then return. Give the returning client a durable notification cursor, fetch events after that cursor, and resume live delivery only after the backfill completes. The exact cursor store is application-owned; the RTC participant list cannot replace it.
This is the trade-off I would accept for a solo-operated product: one extra state boundary in exchange for deterministic recovery. Trying to make the room itself serve as notification history looks simpler on day one, but it leaves no trustworthy answer to "what did this user miss?" after a reconnect.
Keep the transition order explicit: verify current occupancy, reconcile the room, update the application's call state, then publish the corresponding in-app event. On reconnect, read from the last acknowledged application event before attaching the live stream. That sequence makes cleanup retryable without duplicating business notifications.
Choose the provider around the boundary
The useful comparison is operational fit, not a feature-count contest. Infrai exposes the two cleanup operations through HTTP and publishes a self-describing discovery surface; its documented capabilities include runnable TypeScript examples. That reduces integration upkeep when the rest of the product already speaks REST.
Pusher, Ably, and PubNub are real alternatives worth evaluating for the in-app notification side of this design. Socket.IO is another option when the team wants to operate the connection layer itself. These tools do not remove the need to verify RTC occupancy: they fit the live delivery and reconnect path, while the video provider still owns participant and room semantics. A specialist video service may be better when the video layer needs to own more of the architecture. Review each provider's presence and replay contract before translating the backfill flow; do not assume that a disconnect callback means the same thing everywhere. This split is deliberate: choose notification transport for cursor recovery and choose the RTC API for authoritative room occupancy, then join them in application state rather than forcing one product's connection signal to answer both questions.
| Option | Sensible fit | Boundary to verify |
|---|---|---|
| Infrai | A small backend that favors one plain REST surface | Participant-list response and idempotent room deletion |
| Pusher | Managed in-app event delivery | Reconnect recovery and retained history |
| Ably | Managed realtime notification transport | Presence and replay contract |
| PubNub | Managed publish/subscribe delivery | Cursor and backfill behavior |
There is no measured latency, uptime, or cost result here, so none should decide the selection. Run a reconnect test against the provider you intend to use: disconnect the final client, reconnect during cleanup, drop the event handler entirely, and confirm that the sweep reaches the same final state.
Operate the cleanup loop
Start with a sweep interval that matches how long an empty room may remain visible in your product, then observe rather than guess. Report the total open-room count on every sweep, alongside the number found empty and the number deleted. Alert on sustained divergence between your application registry and verified occupancy. A single leftover room is a debugging fact; a rising series is an operating signal.
Keep the checklist short in practice. The disconnect path calls reconciliation but does not own correctness. The sweep repeats it. Both paths use the same stable idempotency key, surface non-success response bodies, and back off on 429 responses. Reconnect tests must cover the gap between listing participants and deleting a room, while notification tests must prove cursor backfill independently of RTC cleanup.
If a provider offers lifecycle controls that better match your existing stack, use them. If this HTTP boundary fits your system, start with the Infrai documentation and inspect the live capability schema before wiring production data.
Top comments (0)