WebSocket Signaling Implementation: Room Routing, Reconnection & Backpressure
A WebRTC media session cannot begin until two peers have exchanged a session description and a stream of ICE candidates over a side channel. That side channel is the signalling server, and in production it is almost always a WebSocket: a persistent, full-duplex connection that delivers SDP and candidate payloads in sub-10 ms without the polling overhead of HTTP. This guide is part of the WebRTC Protocol Stack & Signaling Servers guide, and it walks through building a signalling layer that routes messages to the right room, survives the Wi-Fi-to-cellular transitions that drop sockets mid-call, applies backpressure before the event loop stalls, and rejects malformed payloads at the boundary. The goal is a server you can run behind a load balancer and trust to deliver every offer, answer, and candidate exactly once to exactly the right peers.
The signalling server is deliberately dumb about media. It never parses RTP, never terminates DTLS, and never sees decrypted audio or video. It is a typed message router that maps a roomId onto a set of live sockets and forwards opaque payloads between them. Keeping that boundary clean is what lets the same server handle a two-person call and a 50-person conference without code changes — the routing logic is identical, only the fan-out width differs.
Step 1 — Room Routing
The core data structure is a Map<roomId, Set<WebSocket>>. Every inbound message names a room; the server looks up the set and forwards the payload to every member except the sender. The sender exclusion matters — echoing an offer back to its originator triggers InvalidStateError when the peer tries to apply its own SDP as a remote description. Keep room membership a Set rather than an array so that joins are idempotent: a client that reconnects and re-joins must not appear twice and receive every message in duplicate.
Attach a stable, server-assigned identifier to every socket at connection time. Clients should never supply their own peer ID, because a malicious or buggy client could collide with another peer and hijack its routing slot. Generate the ID server-side with a UUID and stamp it onto each forwarded message as senderId so the receiving peer knows which transceiver the offer belongs to.
const { WebSocketServer } = require('ws');
const { randomUUID } = require('crypto');
const wss = new WebSocketServer({ port: 8080, maxPayload: 65536 });
const rooms = new Map(); // Map<roomId, Set<ws>>
function joinRoom(roomId, ws) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId).add(ws);
ws.roomId = roomId; // remember for O(1) cleanup on close
}
function routeToRoom(roomId, payload, sender) {
const room = rooms.get(roomId);
if (!room) return;
const data = JSON.stringify({ ...payload, senderId: sender.peerId });
for (const peer of room) {
// Exclude the sender; skip sockets mid-close to avoid throwing
if (peer !== sender && peer.readyState === peer.OPEN) peer.send(data);
}
}
wss.on('connection', (ws) => {
ws.peerId = randomUUID(); // server-assigned identity, never client-supplied
});
Track ws.roomId on the socket so teardown is O(1): when a socket closes you delete it from one set, not by scanning every room. For rooms that span more than one server process, this in-memory map is no longer sufficient — a peer connected to node B must still receive a message published on node A. That fan-out across nodes is the subject of the Scaling WebSocket Signaling with Redis Pub/Sub deep-dive.
Liveness Detection and Ghost Room Membership
TCP does not tell you when a peer disappears. If a laptop lid closes or a phone drops off the network without sending a FIN, the server’s socket stays in readyState === OPEN indefinitely and send() keeps succeeding, writing bytes into a kernel buffer that will never be acknowledged. Linux gives up only after tcp_retries2 retransmissions — 15 by default, roughly 13 to 30 minutes of exponentially spaced probes — so a room can carry dead members for a quarter of an hour. The visible symptom is a call that never starts: a joining peer sends an offer, the server dutifully forwards it to a socket belonging to a phone that is already in someone’s pocket, and no answer ever comes back. Every peer then waits out the ICE timeout instead of failing fast.
The fix is a heartbeat driven by the server, not the client. No browser exposes WebSocket ping/pong control frames to JavaScript, and neither Chrome nor Firefox emits them on its own — Firefox’s network.websocket.timeout.ping.request is 0 (disabled) unless a user changes it. So the server sweeps: mark every socket unhealthy, send a ping, and let the automatic pong from the browser’s networking stack mark it healthy again before the next sweep.
const HEARTBEAT_MS = 30000; // must stay below every proxy idle timeout on the path
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; }); // browsers pong inside the stack
});
const sweep = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) { ws.terminate(); continue; } // destroy, not close()
ws.isAlive = false;
ws.ping(); // no client-side JS API exists for this; the browser answers
}
}, HEARTBEAT_MS);
wss.on('close', () => clearInterval(sweep)); // don't leak the timer between tests
Use terminate() and not close() for the unhealthy case. close() starts the closing handshake: it sends a close frame and waits for the peer’s close frame in reply, which a machine that has vanished will never send, so the socket lingers until the library’s own 30-second closing timeout fires and the ghost stays in the room for that whole window. terminate() destroys the underlying socket immediately and fires close locally, which is what runs your O(1) room-cleanup path. A 30-second sweep also doubles as a NAT keepalive — middleboxes and carrier-grade NAT commonly expire idle mappings in under 30 s — and costs almost nothing: 10,000 sockets pinged every 30 s is about 333 two-byte control frames per second.
Step 2 — Reconnect & Backoff
Mobile clients change networks constantly. A handoff from Wi-Fi to LTE — the same transition that forces the media path to re-evaluate its candidate pairs, walked through in Handling Wi-Fi to Cellular Network Handover — drops the TCP connection underneath the WebSocket, and the browser surfaces this as a close event with code 1006 (abnormal closure), not a clean 1000. The client must distinguish 1006 (reconnect aggressively) from 1001 Going Away sent during a planned server drain (reconnect, but the server is healthy) and 1000 (intentional, do not reconnect).
Reconnect with exponential backoff plus jitter. Without jitter, a server restart causes every disconnected client to reconnect at the same instant — a thundering herd that knocks the server over again. A base of 500 ms doubling to a cap of 8–10 s, with ±30% randomised jitter, spreads the reconnect storm across a window.
// Client-side reconnect with exponential backoff and jitter
let attempt = 0;
function connect() {
const ws = new WebSocket('wss://signal.example.com/ws');
ws.onopen = () => {
attempt = 0; // reset backoff on a clean open
ws.send(JSON.stringify({ type: 'rejoin', roomId, lastSeq }));
};
ws.onclose = (e) => {
if (e.code === 1000) return; // intentional close, do not reconnect
const base = Math.min(500 * 2 ** attempt, 10000); // cap at 10 s
const jitter = base * 0.3 * (Math.random() * 2 - 1); // ±30%
attempt++;
setTimeout(connect, base + jitter);
};
}
Reconnecting the signalling socket does not by itself disturb the media session. An RTCPeerConnection keeps its ICE and DTLS state alive independently of the WebSocket — a 4-second signalling outage during an active call is invisible to the media plane. Only if ICE itself reports failed should the client trigger an ICE restart with createOffer({ iceRestart: true }), capped at 3 retries. On rejoin, send the last sequence number you processed so the server can detect whether you missed any messages while disconnected; replaying that gap instead of tearing the room membership down and rebuilding it is covered in Reconnecting Signaling Sockets Without Losing Session State. The full set of reconnection-aware state transitions belongs to Signaling State Machine Patterns.
Proxy and Load-Balancer Idle Timeouts
Not every 1006 starts at the client’s radio. Once negotiation completes, a signalling socket can sit idle for the entire remaining length of a call, and every intermediary on the path has an opinion about idle connections: an AWS Application Load Balancer closes them after 60 seconds by default, nginx’s proxy_read_timeout is likewise 60 seconds, and Cloudflare’s WebSocket proxy allows roughly 100 seconds. The client then sees an abnormal close on a perfectly healthy network, reconnects, goes idle again, and repeats the cycle for the whole call.
The fixed cadence is the diagnosis. Log the wall-clock gap between the last frame written on a socket and its close event: a genuine network handoff produces a scattered distribution, whereas a proxy timeout clusters tightly at 60 s with almost no variance. When you see that spike, the fix belongs in the proxy configuration and the server heartbeat, not in the client’s backoff curve.
# nginx: proxy the upgrade correctly and stop reaping idle signalling sockets
location /ws {
proxy_pass http://signal_backend;
proxy_http_version 1.1; # the upgrade hop must be HTTP/1.1
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 120s; # comfortably above the 30 s heartbeat
proxy_send_timeout 120s;
}
Raising the proxy timeout without adding a heartbeat only postpones the failure — the periodic ping is what makes the connection non-idle, so keep the sweep interval strictly below the smallest timeout on the path, leaving room for one lost ping. Separately, resist wiring reconnection logic to the media plane’s iceConnectionState === 'disconnected': that state fires after a couple of seconds of missing connectivity checks and usually clears itself, while failed means the ICE agent has exhausted its candidate pairs. The timers behind each are unpacked in Disconnected vs Failed ICE States, and confusing the two produces a storm of needless renegotiation on exactly the flaky networks that can least afford it.
Step 3 — Backpressure
A WebSocket send is not instantaneous. When a slow consumer cannot drain its receive buffer as fast as you push to it, the kernel and the ws library queue the unsent bytes in ws.bufferedAmount. Ignore this and a single slow peer in a busy room will balloon your process heap until the event loop stalls and every other call degrades. Backpressure is the discipline of refusing to enqueue more than a peer can absorb.
Set a high-water mark on bufferedAmount. When a peer exceeds it, stop forwarding non-critical traffic to that peer — or, for a peer that stays saturated for several seconds, close it with code 1013 (Try Again Later) and let it reconnect to a less loaded node. Critically, never let one slow subscriber block the broadcast loop for the whole room; forward to fast peers immediately and drop or defer for the slow one. The same counter exists on the peer-to-peer side, where an event-driven low-water mark replaces polling — see Backpressure with bufferedAmountLowThreshold for that variant.
const HIGH_WATER = 1 << 20; // 1 MiB of un-flushed bytes per socket
function safeSend(peer, data) {
if (peer.readyState !== peer.OPEN) return;
if (peer.bufferedAmount > HIGH_WATER) {
// Slow consumer: shed load rather than growing the heap unbounded
peer._slowSince ??= Date.now();
if (Date.now() - peer._slowSince > 5000) peer.close(1013, 'backpressure');
return; // skip this peer for this message
}
peer._slowSince = undefined;
peer.send(data);
}
On the inbound side, the ws library pauses reading from a socket automatically when your message handler is async and slow, but only if you actually await the work. If you fire async validation without awaiting, inbound frames pile up unbounded. Offload CPU-heavy validation (large SDP, schema checks) so the main thread keeps servicing heartbeats — a blocked main thread misses pong deadlines and the client wrongly concludes the connection is dead.
Head-of-Line Blocking on the Signalling Path
Sub-10 ms delivery over a WebSocket is a median, not a guarantee, and the reason is TCP. Every signalling frame shares one connection, so a single lost segment stalls all the messages queued behind it until the retransmission lands. On a link with 2% loss and a 100 ms round trip, that one loss costs at least a full RTT before the sender retransmits and the receiver can release the buffered bytes: a trickled ICE candidate that should have arrived in 8 ms turns up 150–300 ms late, eroding a good share of the 200–800 ms of first-frame latency that trickling is supposed to buy over bulk gathering. The effect is invisible inside a datacentre and glaring over a congested cellular uplink, which is why synthetic tests rarely surface it.
Two mitigations earn their keep. Disable Nagle’s algorithm on server sockets — signalling frames are small and bursty, and Nagle will hold a 200-byte candidate waiting for more data to coalesce, interacting with delayed ACKs to add tens of milliseconds per message. The ws library sets noDelay: true on incoming connections, but a custom net.Server or a TCP proxy in front of it needs the same treatment. Then reduce how much can queue at all: coalescing a burst of trickled candidates into one frame every 50 ms cuts the number of messages that can sit behind a single loss, at the cost of up to 50 ms of added latency per candidate. Turn that on above a measured loss threshold rather than unconditionally.
Ordering has a useful corollary. Because one connection means strict FIFO delivery, an offer sent before its candidates always arrives before them, so a receiver never has to buffer candidates for an SDP that has not landed yet. That guarantee is per connection only — it says nothing about the relative order of two different peers’ messages, so simultaneous offers from both ends still collide, and untangling that collision is the polite-peer negotiation described in Recovering from Glare in Offer Collisions.
Step 4 — Message Validation & Verification
Every inbound frame is untrusted. Parse JSON inside a try/catch, reject anything that is not an object, and validate against a strict allow-list of message types and required fields before routing. A signalling server that forwards arbitrary client payloads is an open relay: an attacker can broadcast junk to every peer in a room, inject crafted SDP to crash peers, or amplify traffic. Validate type, roomId, and a bounded payload size; reject unknown types with an explicit error rather than silently dropping them, so clients fail loud during development. Note what validation does not have to do: the SDP body stays opaque, because a swapped fingerprint is caught by the media handshake itself, as Verifying DTLS Fingerprints to Prevent MITM explains.
const ALLOWED = new Set(['join', 'rejoin', 'offer', 'answer', 'candidate', 'leave']);
function validate(raw) {
let msg;
try { msg = JSON.parse(raw); } catch { return { error: 'INVALID_JSON' }; }
if (typeof msg !== 'object' || msg === null) return { error: 'NOT_OBJECT' };
if (!ALLOWED.has(msg.type)) return { error: 'UNKNOWN_TYPE' };
if (typeof msg.roomId !== 'string' || msg.roomId.length > 128) return { error: 'BAD_ROOM' };
return { msg };
}
wss.on('connection', (ws) => {
ws.on('message', (raw) => {
const { msg, error } = validate(raw);
if (error) { ws.send(JSON.stringify({ type: 'error', error })); return; }
if (msg.type === 'join' || msg.type === 'rejoin') joinRoom(msg.roomId, ws);
else routeToRoom(msg.roomId, msg, ws);
});
ws.on('close', () => {
const room = rooms.get(ws.roomId);
if (room) { room.delete(ws); if (room.size === 0) rooms.delete(ws.roomId); }
});
});
Authenticating the Upgrade
Validation and authentication are different jobs, and the browser makes the second one awkward: the WebSocket constructor accepts no custom headers, so there is nowhere to put an Authorization bearer token. Three workarounds are in common use, each with a real cost. A token in the query string is simplest and lands in every access log, proxy log, and error report along the path — tolerable only if the token is single-use and expires in about 60 seconds. Overloading Sec-WebSocket-Protocol to carry the token works in every current browser, but the server must echo one of the offered subprotocol values back in the handshake response or Chrome and Safari fail the connection outright. A session cookie is cleanest when the signalling host is same-site and useless when it is not, because SameSite=Lax suppresses the cookie on a cross-site upgrade.
Whatever the carrier, reject unauthenticated clients during the HTTP upgrade event rather than after the socket exists. WebSocket is exempt from the same-origin policy — no preflight, no CORS — so the Origin header the browser always sends is the only CSRF-equivalent check available to you, and a handshake you allow to complete has already consumed a file descriptor plus a slot in your connection budget.
const { createServer } = require('http');
const server = createServer();
const wss = new WebSocketServer({ noServer: true, maxPayload: 65536 });
server.on('upgrade', async (req, socket, head) => {
if (req.headers.origin !== 'https://app.example.com') { // no CORS on WebSocket
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
const token = new URL(req.url, 'http://x').searchParams.get('t');
const claims = await verifyShortLivedToken(token); // ~60 s TTL, single use
if (!claims) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); // no socket allocated
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws.userId = claims.sub; // trusted identity
wss.emit('connection', ws, req);
});
});
Rate-limit each socket once it is authenticated, and size the limit for ICE rather than for chat. A token bucket of roughly 50 messages per second with a burst allowance of 100 is safe; a dual-stack host legitimately trickles 20–40 candidates in the first second of gathering, so a limit tuned to steady-state traffic will silently discard real candidates and produce intermittent failures that look exactly like NAT traversal problems.
Verification checklist:
- Two browser tabs join the same
roomId - Send
{ "type": "offer" }with noroomIdand confirm the server replies with aBAD_ROOM - Throttle one peer’s network in DevTools and watch
bufferedAmount - Load-test with
k6orartillery
A working signalling channel is the prerequisite for ICE Candidate Gathering & Filtering, since trickled candidates ride this same channel. For the framework-specific build on top of these four steps, see WebSocket Signaling with Node.js & Socket.IO.
Edge Cases & Browser Quirks
Concurrent connection caps. Chrome and Firefox cap WebSocket connections at roughly 200–256 per origin (Chrome historically 256, Firefox governed by network.websocket.max-connections, default 200). A tab that opens a separate socket per call hits the ceiling fast; multiplex all signalling for a tab over one connection.
Safari close-code reporting. Safari (through 17) is less consistent than Chrome about surfacing distinct close codes; abnormal drops frequently arrive as 1006 with no reason string. Do not branch reconnection logic on the reason text — branch only on the numeric code, and treat any 1006 as “reconnect with backoff.”
Firefox aggressive idle timeout on cellular. On some Android builds, Firefox’s underlying connection is reclaimed faster than Chrome’s during background tabs. Keep ping/pong heartbeats at 30–45 s to refresh NAT bindings before carrier-grade NAT (which can expire UDP mappings in under 30 s) or the browser reclaims the socket.
perMessageDeflate memory on Chrome. Enabling per-message compression saves bandwidth on large SDP but allocates a compression context per connection; under tens of thousands of sockets this is real memory. Measure before enabling it server-wide.
mDNS .local candidates. Modern Chrome and Firefox mask host IPs behind .local mDNS hostnames in candidates. Your signalling server must forward these strings verbatim — do not “normalise” them, or peers cannot resolve the obfuscated host.
Background-tab timer throttling. Chrome throttles setTimeout in hidden tabs to about once per minute after five minutes of backgrounding, and can freeze eligible tabs outright. A reconnect scheduled with a 4-second backoff may therefore not fire until the tab is foregrounded, and the user comes back to a stale, disconnected UI that eventually heals for no visible reason. Listen for visibilitychange, and when the document becomes visible cancel the pending timer and dial immediately.
iOS Safari and WKWebView suspension. Backgrounding an iOS app suspends its networking; on resume the socket is functionally dead while readyState can still report OPEN for a short window, so a send() disappears without an error. Probe on resume — emit a lightweight application-level ping and treat no reply within 3–5 s as a dead socket — rather than trusting readyState after any suspension.
Back/forward cache. Chrome and Safari close open WebSockets when a page enters the back/forward cache, and returning via the back button restores the DOM without re-running your bootstrap code. Reconnect explicitly on pageshow when event.persisted is true; relying on the close handler alone leaves the restored page permanently silent.
No browser-initiated pings. No major browser sends WebSocket ping frames of its own accord, and none exposes ping or pong to JavaScript. Any keepalive you depend on is either a server-initiated control frame or an application-level message — worth verifying explicitly when you inherit a client that claims to “have heartbeats”.
Common Implementation Mistakes
- Echoing to the sender. Forgetting the
peer !== senderguard makes a peer apply its own offer as a remote description and throwInvalidStateError. - Client-supplied peer IDs. Trusting a client-provided identity lets one client overwrite another’s routing slot. Assign IDs server-side.
- Scanning every room on disconnect. Iterating all rooms on each
closeis O(rooms); storews.roomIdand delete in O(1). - Treating
1006as fatal. Abnormal closure is the normal outcome of a network handoff, not an error to surface to the user — reconnect silently. - Unbounded
bufferedAmount. No backpressure means one slow consumer grows the heap until the event loop stalls for everyone. - Renegotiating media on signalling reconnect. The
RTCPeerConnectionsurvives a signalling drop; only restart ICE if ICE itself reportsfailed. - Forwarding unvalidated payloads. An open relay lets attackers broadcast junk to every peer; validate
type,roomId, and size at the boundary. - Closing a dead socket with
close(). The closing handshake waits for a reply that a vanished peer will never send, keeping a ghost in the room; useterminate()once a heartbeat sweep has already declared it unhealthy. - A heartbeat slower than the shortest proxy timeout. A 60-second ping against a 60-second load-balancer idle timeout is a race you lose regularly; keep the sweep at half the tightest timeout on the path.
- Rate limits sized for chat traffic. Trickle ICE bursts 20–40 candidates in the first second; a per-socket cap below that drops candidates and produces failures that get misdiagnosed as NAT problems.
- Never authenticating the upgrade. Checking credentials after the socket exists means every unauthenticated client still costs a file descriptor and a handshake; reject in the
upgradehandler.
FAQ
Do I need sticky sessions if I run more than one signalling node?
Sticky sessions keep a given client pinned to one node so its in-memory room map stays consistent, but they do not solve cross-node fan-out: two peers in the same room may land on different nodes. You need either sticky routing plus a shared message bus, or a stateless design with a pub/sub backplane. The trade-offs are covered in the Scaling WebSocket Signaling with Redis Pub/Sub guide.
Does a dropped WebSocket drop the call?
No. Media flows peer-to-peer over a separate DTLS-SRTP path. The signalling socket is only needed to negotiate or renegotiate. A brief signalling outage during an established call is invisible to media; only an ICE failed state requires action.
How large can a signalling message get?
SDP payloads for a multi-track session can reach a few kilobytes; bundled simulcast offers more. Cap maxPayload at 64 KiB to bound memory, which comfortably fits realistic SDP while rejecting abusive frames.
Should signalling be encrypted at the application layer?
WSS gives you transport encryption, which is sufficient — the SDP carries a DTLS fingerprint that WebRTC validates cryptographically during the handshake, so a tampered SDP fails the media handshake. Application-layer encryption is defence-in-depth, rarely required.
How do I stop a reconnect loop when authentication fails?
Reserve an application close code in the 4000–4999 private range — 4401, say — and send it when a token is rejected at upgrade or revoked mid-session. The client then treats 4401 exactly as it treats 1000: stop retrying, refresh credentials through the normal auth path, and only dial again afterwards. Without a distinct code the client cannot tell “you are unwelcome” from “the network blinked”, so an expired token becomes an infinite backoff loop hammering your auth service every 10 seconds per client.
How many signalling sockets fit in one Node process?
Budget 30–50 KB of resident memory per idle socket once per-connection library state and the room-map entry are counted, putting 10,000 concurrent sockets in the 300–500 MB range before perMessageDeflate adds its own zlib context per connection. Raise the file-descriptor ulimit above the socket count, then watch event-loop lag rather than CPU: a healthy signalling node stays within a few milliseconds of lag even while fanning out to thousands of peers, because each forwarded message is one string serialisation and a write.
Is a WebSocket the only sensible signalling transport?
No, but it is the default for good reasons: bidirectional, low framing overhead, and universally proxied on 443. Server-sent events plus HTTP POST works and costs you a second connection with worse upstream latency. Where a typed schema and a service mesh already exist, Custom Signaling Protocols with gRPC-Web is a credible alternative, at the price of a proxy that can translate gRPC-Web framing.
Related: this guide sits under WebRTC Protocol Stack & Signaling Servers; build the concrete server with WebSocket Signaling with Node.js & Socket.IO, scale it horizontally via Scaling WebSocket Signaling with Redis Pub/Sub, model the transitions with Signaling State Machine Patterns, and feed candidates through ICE Candidate Gathering & Filtering.