Skip to main content

05 — WebSocket Signaling Protocol

One WebSocket per client session, established after a successful POST /rooms/{id}/join. The socket carries WebRTC negotiation (proxied to the SFU) and room-level realtime events (roster sync, active speakers, moderation). Clients never open a second control channel.

Connection & Authentication

wss://signal.<region>.<domain>/v1/ws?ticket=st_...
  • The ticket is a one-time, 30-second-TTL token minted by the join call (avoids putting long-lived JWTs in URLs, which get logged). The Signaling instance redeems it in Redis (atomic GETDEL) and binds the socket to {user_id, room_id, session_id}.
  • On success the server sends hello; on failure it closes with code 4401 before any message.

Envelope

Every frame is a JSON object:

{
"v": 1, // protocol version
"type": "sdp_offer", // message type
"seq": 42, // sender-side monotonically increasing sequence
"ts": 1789300800123, // sender unix ms
"payload": { ... }
}
  • seq enables resume-after-reconnect: the server buffers outbound messages per session (Redis, 90 s window) and replays anything the client missed.
  • Unknown type must be ignored (forward compatibility); unknown v → close 4400.

Message Catalog

Session control (bidirectional)

TypeDirectionPayloadNotes
helloS→C{ session_id, resume: bool, server_time, heartbeat_interval_ms: 15000 }first frame
pingC→S{}every heartbeat_interval_ms
pongS→C{ server_time }RTT measurable client-side
resumeC→S{ session_id, last_seq }on reconnect instead of full join
resumedS→C{ replayed: n }followed by replayed frames
errorS→C{ code, message, fatal: bool }fatal → client must rejoin via REST
byeC→S / S→C{ reason }graceful close; server reasons: kicked, room_closed, session_superseded, idle_timeout

WebRTC negotiation (client ↔ SFU, relayed by Signaling)

TypeDirectionPayload
sdp_offerC→S or S→C{ sdp } — server-initiated on subscription changes (SFU renegotiation)
sdp_answerC→S or S→C{ sdp }
ice_candidateC→S or S→C{ candidate, sdp_mid, sdp_mline_index } — trickle ICE
ice_restartS→C{ reason } — instructs client to restart ICE (node drain, network change); client re-fetches TURN creds if near expiry
media_readyS→C{} — SFU confirms first media flowing (analytics + UX signal)

Room events (server → client)

TypePayload
participant_joined{ user_id, display_name, role, muted }
participant_left{ user_id, reason }
roster_snapshot{ participants: [...], epoch } — sent on join/resume and on epoch bump; clients reconcile against it, incremental events apply on top
active_speakers{ user_ids: [..] } — throttled to ≤ 3/s
role_changed{ user_id, role }
muted / unmuted{ user_id, by: "self" | "moderator" }
room_closing{ reason, grace_s }

Client requests over WS (thin — mutations go through REST)

TypePayloadNotes
set_mute{ muted: bool }self-mute only; moderator mute is REST
request_speak{}listener raises hand → moderators get event
state_query{}server replies roster_snapshot

Design rule: anything that changes durable state goes through REST (auditable, rate-limited, idempotent); the socket is for negotiation and ephemeral fan-out. This keeps Signaling instances trivially replaceable.

Connection State Machine

Server-side per session, mirrored in signal:session:{id} (Redis) so any instance can adopt a resuming client.

Heartbeats & Liveness

  • Client ping every 15 s; server marks session dead after 2 missed intervals (~30 s) → presence expiry, participant_left(reason: disconnected) to the room, SFU-side participant timeout handles media teardown.
  • Server also relies on TCP/WS close events for fast detection; heartbeat is the backstop for half-open connections.
  • Media liveness is independent: SFU detects RTP/DTLS timeout separately — a dead WS with live media gives the client the 90 s resume window before it's ejected.

Reconnection & Resume

  1. Client reconnects (exponential backoff 0.5 s → 8 s, jittered), sends resume { session_id, last_seq }.
  2. Any Signaling instance loads session from Redis, re-binds the socket, replays buffered frames > last_seq, sends fresh roster_snapshot.
  3. If media also dropped: server issues ice_restart; client refreshes ICE credentials if TTL < 120 s and renegotiates. Publisher/subscriber state is restored by the SFU (LiveKit native resume).
  4. Past the 90 s window: 4404 session_expired → full REST rejoin.

A client that opens a second socket for the same session_id supersedes the first (bye {session_superseded} to the old one) — handles zombie tabs and app relaunches.

Participant Synchronization

Roster consistency model: snapshot + ordered deltas per room epoch. The server maintains epoch (bumped when Signaling loses confidence in delta continuity, e.g. Redis pub/sub gap). Clients apply deltas matching their epoch; on mismatch they request state_query. This gives eventual consistency with a hard reconciliation path and no client-side guesswork.

Close Codes

CodeMeaningClient action
1000normalnone
4400protocol violation / bad versionrejoin, report
4401auth failed / ticket invalidre-auth, rejoin
4403kicked / bannedsurface to user, no auto-retry
4404session expiredfull rejoin
4409superseded by newer connectionnone (old tab)
4503instance drainingimmediate reconnect (resume) — used during rolling deploys