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 code4401before 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": { ... }
}
seqenables resume-after-reconnect: the server buffers outbound messages per session (Redis, 90 s window) and replays anything the client missed.- Unknown
typemust be ignored (forward compatibility); unknownv→ close4400.
Message Catalog
Session control (bidirectional)
| Type | Direction | Payload | Notes |
|---|---|---|---|
hello | S→C | { session_id, resume: bool, server_time, heartbeat_interval_ms: 15000 } | first frame |
ping | C→S | {} | every heartbeat_interval_ms |
pong | S→C | { server_time } | RTT measurable client-side |
resume | C→S | { session_id, last_seq } | on reconnect instead of full join |
resumed | S→C | { replayed: n } | followed by replayed frames |
error | S→C | { code, message, fatal: bool } | fatal → client must rejoin via REST |
bye | C→S / S→C | { reason } | graceful close; server reasons: kicked, room_closed, session_superseded, idle_timeout |
WebRTC negotiation (client ↔ SFU, relayed by Signaling)
| Type | Direction | Payload |
|---|---|---|
sdp_offer | C→S or S→C | { sdp } — server-initiated on subscription changes (SFU renegotiation) |
sdp_answer | C→S or S→C | { sdp } |
ice_candidate | C→S or S→C | { candidate, sdp_mid, sdp_mline_index } — trickle ICE |
ice_restart | S→C | { reason } — instructs client to restart ICE (node drain, network change); client re-fetches TURN creds if near expiry |
media_ready | S→C | {} — SFU confirms first media flowing (analytics + UX signal) |
Room events (server → client)
| Type | Payload |
|---|---|
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)
| Type | Payload | Notes |
|---|---|---|
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
pingevery 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
- Client reconnects (exponential backoff 0.5 s → 8 s, jittered), sends
resume { session_id, last_seq }. - Any Signaling instance loads session from Redis, re-binds the socket, replays buffered frames
> last_seq, sends freshroster_snapshot. - 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). - 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
| Code | Meaning | Client action |
|---|---|---|
| 1000 | normal | none |
| 4400 | protocol violation / bad version | rejoin, report |
| 4401 | auth failed / ticket invalid | re-auth, rejoin |
| 4403 | kicked / banned | surface to user, no auto-retry |
| 4404 | session expired | full rejoin |
| 4409 | superseded by newer connection | none (old tab) |
| 4503 | instance draining | immediate reconnect (resume) — used during rolling deploys |