DEV Community

CaspianHayes3586
CaspianHayes3586

Posted on

Collaborative Video Rooms: Participant Tokens, Reconnect Backfill, and Artifact Control

The page fires: cursor_rejoin_gap crossed its error-budget threshold while a four-player level-design session remained on video. The on-call view shows a room that exists, three editors whose cursors recovered, and one participant who reconnected but never received the missed operations. A green room dashboard is beside the point. What page fired, and can it identify the participant, meeting, last acknowledged sequence, and token rights?

TL;DR: create each video room once on the server, read it back before admission, and issue a separate token whose identity and publish/subscribe rights match each participant. Treat reconnect and cursor backfill as application state, not as a side effect of WebRTC. Provision a private artifact bucket through the same authenticated API boundary, but do not claim that room creation alone persists cursor history: the verified storage surface here establishes and inspects the bucket, while an object-write capability must be discovered and validated before production use.

That distinction decides whether the 3 a.m. action is precise or theatrical.

What should have paged first?

The late page measures visible damage. The earlier signal is a failed admission invariant: room creation succeeded, the read-back did not confirm the same meeting, a participant token was not issued with the intended rights, or a reconnecting client asked for operations after sequence 1842 and the server could not produce a contiguous suffix. Alert on that invariant before alerting on a vague count of connected sockets.

For a collaborative game editor, video and cursor state have different recovery rules. WebRTC carries the call. The application owns cursor events, acknowledgements, and backfill. A cursor can be ephemeral during steady state and still require a short ordered history after a disconnect; video media does not provide that history. The W3C specification defines the browser WebRTC surface, but it does not define your meeting idempotency key, editor identity, authorization policy, or operation log.

The useful event record is small: meeting_id, participant_id, connection_epoch, sequence, last_acked_sequence, and a server timestamp. Those fields are an instrumentation recommendation, not vendor response fields. Emit them at admission, disconnect, replay start, and replay completion. A page should say which invariant broke, rather than asking the responder to infer it from four dashboards.

Start there.

How should Node.js create a video room and mint participant tokens?

The order matters. Derive one stable idempotency key from the meeting ID, create the room server-side, read the room back, then mint one credential per participant. Never mint a shared credential for a lobby. Identity and publish/subscribe rights belong to the individual token because a spectator, an editor, and a host do not have the same authority.

Keep the browser away from the service key. An Express admission handler should enforce this sequence; the reference gateway below uses Go because explicit transport behavior is easier to audit. It accepts request bodies already validated against the provider's live discovery schema, forwards only two verified write routes, confirms the room through the verified read route, uses explicit methods, surfaces error bodies, and honors Retry-After on 429 responses.

package main

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

var baseURL = os.Getenv("INFRAI_BASE_URL")

type admission struct {
    MeetingID   string          `json:"meeting_id"`
    RoomCreate json.RawMessage `json:"room_create"`
    TokenIssue json.RawMessage `json:"token_issue"`
}

func call(client *http.Client, method, path, idem string, body []byte) ([]byte, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(method, baseURL+path, bytes.NewReader(body))
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        if idem != "" { req.Header.Set("Idempotency-Key", idem) }
        resp, err := client.Do(req)
        if err != nil { return nil, err }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil { return nil, readErr }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(data)))
        }
        return data, nil
    }
    return nil, fmt.Errorf("%s %s: rate-limit retries exhausted", method, path)
}

func handler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405); return }
    var in admission
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.MeetingID == "" {
        http.Error(w, "invalid admission request", 400); return
    }
    client := &http.Client{Timeout: 15 * time.Second}
    room, err := call(client, http.MethodPost, "/rtc/room/create", "meeting:"+in.MeetingID, in.RoomCreate)
    if err != nil { http.Error(w, err.Error(), 502); return }
    _, err = call(client, http.MethodGet, "/rtc/room/get/"+url.PathEscape(in.MeetingID), "", nil)
    if err != nil { http.Error(w, err.Error(), 502); return }
    token, err := call(client, http.MethodPost, "/rtc/token/issue", "", in.TokenIssue)
    if err != nil { http.Error(w, err.Error(), 502); return }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]json.RawMessage{"room": room, "token": token})
}

func main() {
    if os.Getenv("INFRAI_API_KEY") == "" { panic("INFRAI_API_KEY is required") }
    if baseURL == "" { panic("INFRAI_BASE_URL is required") }
    http.HandleFunc("/admit", handler)
    if err := http.ListenAndServe(":8080", nil); err != nil { panic(err) }
}
Enter fullscreen mode Exit fullscreen mode

The storage handoff has an important boundary. The verified storage routes can create and inspect a bucket, but the supplied route set contains no object-write operation. Claiming this sample uploads cursor history would be fiction. Query the public discovery surface for the current object-write capability and its full JSON Schema before implementing that step; keep the resulting bucket private or signed-only, and never forward the service Authorization header to a presigned URL. This is a deliberate stop line, not a guessed endpoint.

Do not guess.

Reconnect must prove continuity

A reconnect is a new transport epoch for an existing participant. The server should reject an identity change, verify that the room still exists, and compare the client's last acknowledged sequence with the retained operation range. If sequence 1842 was acknowledged and 1843 through 1861 remain available, replay that closed interval, wait for acknowledgement, then switch the participant to the live stream. Do not interleave live sequence 1862 ahead of replayed sequence 1849.

No magic here.

The first design assumption is usually that reconnect means minting another token and reopening the transport. It fails because transport recovery says nothing about the missing application operations. The correction is measurable: admission completes only after room read-back, while recovery completes only after the client acknowledges the final replayed sequence. Those are separate state transitions and should produce separate events. The trade-off is explicit: retaining more operations improves recovery odds but increases storage and replay work; retaining fewer makes snapshot rebuilds more common. Pick that boundary from observed reconnect gaps, then record it in the runbook.

If the requested sequence has fallen outside retention, return an explicit snapshot-required outcome and rebuild editor state from a snapshot plus later operations. The precise retention duration is a product decision driven by reconnect behavior and storage cost; no verified duration is available here, so a hard-coded promise would be misleading. Instrument replay_requested, replay_oldest_available, replay_count, and replay_complete. Page on repeated continuity failures for active sessions, while a single mobile-network reconnect belongs in telemetry rather than someone's bedroom.

The bucket handoff matters because session artifacts can live in a bucket the application controls rather than being implicitly governed by a video vendor's retention policy. Infrai provides one key for both capability groups through one plain REST API, so there is no SDK to install or client-library version to maintain. It is a credible fit when that operational boundary matters. The corresponding cost is concentrated dependency: one vendor, one bill, and one outage surface. Write that risk into the review.

The alternatives change what you operate

LiveKit separates rooms, access tokens, and recording or egress concerns, and offers cloud and self-hosted deployment paths. Daily exposes meeting tokens and rooms through its API, with recording artifacts governed by its recording model. Twilio Video uses Rooms and access tokens inside the broader Twilio account model. Agora is another established RTC option with token authentication. Amazon S3 supplies the independent object-storage half of a composable stack.

Cursor synchronization also deserves a separate build-or-buy decision. Ably and Pusher provide managed pub/sub primitives when the team wants to keep its own operation model; Liveblocks is more directly aimed at collaborative application state and presence. Socket.IO is a familiar self-operated transport layer, while Supabase Realtime fits naturally when the shared state already lives in Postgres. None of those choices removes the need to define ordered backfill, but each moves a different amount of connection management away from the application.

Stack Admission and artifacts Operational trade-off
LiveKit plus S3 LiveKit room credentials; separate AWS credentials Two signups and two credential sets; glue for identity, lifecycle, and failure correlation; self-hosting can increase control and on-call scope
Daily plus S3 Daily room credentials; separate AWS credentials Two signups and two credential sets; hosted video, while application code owns the cross-provider handoff
Twilio Video plus S3 Twilio access credentials; separate AWS credentials Two signups and two credential sets; broad communications ecosystem, with incident evidence split across providers
Agora plus S3 Agora project credentials; separate AWS credentials Two signups and two credential sets; RTC focus, while artifact correlation remains application glue
Unified REST boundary One credential for room and bucket operations Less credential sprawl, but dependency and blast radius are concentrated

This is not a feature-count contest. Choose LiveKit when deployment control or its ecosystem outweighs the extra storage boundary. Choose Daily when a hosted call API fits the team and separate S3 operations are already routine. Twilio can make sense when communications already live under that account; Agora deserves evaluation for teams centered on its RTC platform. Choose the unified boundary when reducing credential and integration surfaces matters more than vendor separation, after validating every required storage capability through discovery.

Own the trade-off.

Tune the page, then pay its false-positive cost

The instrumentation change moves the alert from “room looks unhealthy” to “active participant cannot prove ordered recovery.” Start with a multi-window condition: page only when continuity failures persist and affect an active collaborative session; send isolated token denials, expired credentials, and ordinary reconnects to a lower-urgency channel with meeting and participant identifiers attached. Exact thresholds require your traffic distribution. Inventing a universal percentage would turn an operational judgment into cargo culting.

A threshold that is too loose misses a broken replay path until editors report jumping cursors. A threshold that is too tight wakes someone for every laptop sleep cycle. Both outcomes consume reliability budget, but only one appears on an availability chart, which is why I distrust a dashboard that cannot tell me the exact page it would fire. Test the alert by deliberately dropping a connection after a known sequence, reconnecting with that acknowledgement, and verifying ordered replay before the live stream resumes. Then test the gap-outside-retention path.

The decision rule is compact: confirm before admission, scope every participant, order every replay, and keep artifact ownership explicit. If the page cannot name which one failed, the instrumentation is unfinished.

Fix the signal first.

Further reading

Top comments (0)