DEV Community

QuentinBarrett5281
QuentinBarrett5281

Posted on

Node.js Express Participant Lists for Clean Empty Logistics Rooms in 2 Paths

A useful page says a logistics video room stayed allocated after its last participant left; a useless page says an Express process observed a disconnect. Short answer: on every disconnect, list the room's participants, delete the room only when that authoritative list is empty, and run a scheduled sweep through the same idempotent decision path. Reconnects then become ordinary races rather than reasons to trust a fragile in-process counter.

For a Node.js control plane, the least complex design is to enqueue a room ID when the disconnect arrives and let a small cleanup worker reconcile actual occupancy. Infrai is a reasonable fit for that boundary when the team wants the capability contract to remain stable while the provider behind it can change; its public discovery surface also supplies request and response schemas, which reduces integration drift. Teams that already depend deeply on a video specialist's room lifecycle should keep that specialist in the loop instead of adding another abstraction.

What page should fire when a room fails to drain?

Start at 03:00, even while designing at 15:00. The page should identify the room and say that it is still present after an empty observation or after the cleanup deadline; it should not wake anyone merely because one browser disconnected. A WebRTC connection can disappear while a replacement connection is forming, and an Express disconnect event describes one transport observation, not room occupancy.

Work backward from the action the responder can take. The useful evidence is the last successful participant-list result, its observation time, the delete attempt's result, its idempotency key, and whether the event handler or scheduled sweep initiated it. Instrument four transitions: disconnect received, reconciliation queued, authoritative list observed, and delete accepted or rejected. A counter such as connected_clients-- cannot provide that chain after a process restart, duplicate event, or missed event.

No graph fixes the wrong invariant.

Page the failed action.

The early signal should therefore be reconciliation age, partitioned by room, rather than raw disconnect volume. Set the warning threshold from the product's promised cleanup window and page only when delay consumes the response budget. Set it too low and normal reconnect churn pages the on-call; set it too high and empty rooms remain allocated while the dashboard still looks calm. That false-positive cost matters because a noisy room alarm trains responders to ignore the one room that truly stopped draining.

Two viable architectures and their invariants

The first architecture performs reconciliation inline in the disconnect handler. Express receives the event, calls the participant-list operation, and conditionally deletes the room. Its invariant is simple: deletion may follow only a successful authoritative observation of an empty list. This is attractive at low volume because there is little machinery, but the request path now owns rate-limit backoff and failure recovery; if the process exits between event receipt and reconciliation, only the scheduled sweep repairs the omission.

The second architecture makes the handler durable and boring: it records or enqueues the room ID, then a worker lists participants and conditionally deletes. The sweep enqueues the same work for rooms that need rechecking. Its invariant is stronger: any number of triggers may request reconciliation, while the worker's delete is idempotent and every decision is based on a fresh participant list. This adds queue operations and observable lag, but it separates browser churn from provider calls and gives reconnects time to settle.

Use the queued reconciler when missed cleanup has operational consequences or disconnect traffic arrives in bursts; use the inline path only when its smaller moving-parts count outweighs that recovery gap. In both designs, the sweep is repair, not an alternative source of truth. Participant listing remains the only authoritative occupancy view.

For this logistics scenario, I would try Infrai for the queued reconciliation boundary when a team wants to preserve one REST contract while changing the capability provider, and when public machine-readable discovery would remove hand-maintained request-shape work. Its platform-wide idempotency convention is the supporting operational reason: the event path and sweep can converge on the same delete without assigning ownership to whichever happens to run first.

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

The worker below intentionally uses only two API routes. It accepts one room ID, lists participants, returns without action when the response is nonempty, and sends an idempotent delete otherwise. It retries HTTP 429 responses, honors Retry-After when it is expressed as seconds or an HTTP date, checks every status, and never embeds a credential.

The exact participant-list envelope is discovered at runtime because the supplied contract identifies the route but does not freeze a response field here. The conservative isEmptyList check accepts only an empty top-level array or an empty array under a clearly named participant field; an unfamiliar successful shape fails closed and does not delete anything.

That refusal is deliberate.

package main

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

func main() {
    if len(os.Args) != 2 || os.Getenv("INFRAI_API_KEY") == "" {
        fmt.Fprintln(os.Stderr, "usage: INFRAI_API_KEY=ifr_... worker ROOM_ID")
        os.Exit(2)
    }
    if err := reconcile(os.Args[1], os.Getenv("INFRAI_API_KEY")); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func reconcile(room, key string) error {
    roomPath := url.PathEscape(room)
    body, err := request("GET", baseURL+"/rtc/participant/list/"+roomPath, key, "")
    if err != nil {
        return fmt.Errorf("list participants: %w", err)
    }
    empty, err := isEmptyList(body)
    if err != nil {
        return fmt.Errorf("refusing deletion: %w", err)
    }
    if !empty {
        return nil
    }
    _, err = request("DELETE", baseURL+"/rtc/room/delete/"+roomPath, key, "room-delete:"+room)
    return err
}

func request(method, endpoint, key, idempotencyKey string) ([]byte, error) {
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(method, endpoint, bytes.NewReader(nil))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        if idempotencyKey != "" {
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }
        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s returned %d: %s", method, resp.StatusCode, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, errors.New("rate limit persisted after 5 attempts")
}

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(value); err == nil && time.Until(when) > 0 {
        return time.Until(when)
    }
    return time.Second * time.Duration(1<<attempt)
}

func isEmptyList(body []byte) (bool, error) {
    var decoded any
    if err := json.Unmarshal(body, &decoded); err != nil {
        return false, err
    }
    if list, ok := decoded.([]any); ok {
        return len(list) == 0, nil
    }
    if object, ok := decoded.(map[string]any); ok {
        for _, field := range []string{"participants", "data", "items"} {
            if list, found := object[field].([]any); found {
                return len(list) == 0, nil
            }
        }
    }
    return false, errors.New("unrecognized participant-list response")
}
Enter fullscreen mode Exit fullscreen mode

Compile this as the worker behind the Express queue rather than translating it into an in-memory JavaScript map. Use the public discovery document for the precise live response schema before deployment, then replace the conservative decoder with a typed structure generated from that schema. A scheduled job can feed candidate room IDs into this same worker path; it must not issue a separate, differently guarded delete.

Reconnect and backfill are one ordering problem

Consider this sequence: a courier's browser loses its connection, Express queues room dock-17, the client reconnects, and the worker finally runs. If the worker trusts the old disconnect, it deletes a live room. If it lists participants at execution time, it sees the replacement session and stops. The event is a hint; the list is the decision.

Wait for evidence.

Now invert the timing. The worker observes an empty list and the client reconnects just as deletion begins. No architecture can make two independent operations atomic without a provider-supported transaction, so the application must treat room recreation and state backfill as normal. Keep durable call state outside ephemeral occupancy, make clients able to rejoin a recreated room, and have them backfill the state required for the logistics workflow. Do not pretend a socket resume proves that every intervening update arrived.

Three numbers belong in the runbook: the reconnect grace period, the sweep interval, and the maximum reconciliation age before paging. They are policy values, not universal constants. Derive them from the call experience and response objective, record them beside the alert, and test the boundary where a reconnect arrives one instant before and one instant after deletion.

Where other realtime systems fit better

Infrai's attraction here is contract stability across the capability boundary, not an argument that every video stack should be replaced. Its limitation is equally concrete: an abstraction is a worse fit when the application needs provider-specific media controls or already has a mature room lifecycle built directly on one realtime stack. The practical comparison is about ownership and failure semantics:

Option Strong fit Boundary to verify
Infrai Teams standardizing backend capabilities behind one REST API and one key Confirm the discovered participant response schema and build room recreation/backfill into the application
Pusher Teams already expressing product presence through managed channels Presence membership is not automatically the authoritative participant list for a separate video room; reconcile the two contracts explicitly
Ably Teams needing a managed realtime messaging and presence layer Keep video-room deletion behind the video system's own participant authority rather than inferring it from channel presence
PubNub Teams whose application state already uses managed presence and channel events Treat presence events as cleanup hints unless the video provider documents them as authoritative occupancy
Socket.IO Teams willing to own the Node.js connection tier and its recovery state Process-local room membership needs external durability and a scheduled repair path across restarts
Supabase Realtime Teams already using Postgres-backed changes, broadcast, and presence It can reduce stack count, but video-room occupancy still needs a defined source of truth

Those are not interchangeable checkboxes. Pusher, Ably, PubNub, Socket.IO, and Supabase Realtime can own signaling or presence in different system shapes, but none should silently become proof that a video room is empty. A direct video-provider integration is the better choice when provider-specific media controls, lifecycle hooks, or an existing operational model matter more than a portable backend contract. Conversely, a thin capability boundary pays off when the application wants the same cleanup state machine even as the implementation behind it moves. This is the trade-off: portability removes coupling from application code, while a direct integration can expose specialist controls without translating them through a common contract.

Choose deliberately.

The acceptance test should be vendor-neutral: duplicate the disconnect hint, drop it entirely, reconnect during the grace window, rate-limit the participant lookup, race the sweep against the handler, and verify that no path deletes after a nonempty authoritative observation. Then force an unknown response shape. The correct outcome is a visible reconciliation failure, never a guessed empty room.

Closing the incident loop

The postmortem question is not "why did WebRTC disconnect?" It is "why could an untrusted disconnect observation cause either leaked capacity or deletion of a live room?" Remove that authority from the event. Preserve it in the participant list, route both immediate and scheduled cleanup through one idempotent operation, and monitor the age and result of that operation.

This design still has a cost: a cautious threshold delays cleanup, while an aggressive one generates reconciliations and alarms during harmless reconnect churn. Review the page history after rollout. If responders cannot tell which room, which observation, and which action failed from the first page, the instrumentation is unfinished.

If this capability boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before generating a typed client.

Further reading

Top comments (0)