Signaling and Perfect Negotiation
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
A WebRTC peer connection can carry audio, video and data between two browsers, but the browsers cannot find each other and cannot agree on what to send without help. The messages that carry that agreement (session descriptions and ICE candidates) have to travel over some other channel before the peer connection exists. That channel is called signalling, and WebRTC deliberately leaves it undefined. This page builds the signalling channel of Huddle, the videoconference scenario of this section, as a WebSocket service with rooms, join and leave events, authentication and a relay for offers, answers and candidates. It then implements the perfect negotiation pattern on the browser side so that two peers can add tracks at the same moment, collide, and still converge without any special casing. A Socket.IO variant of the server is included, and the whole thing is tested with two headless Chrome pages that call each other.
The page was written against the W3C WebRTC 1.0 Recommendation (2025-03-13 edition) and JSEP in RFC 9429 (which
obsoletes RFC 8829), ws 8.22.0, socket.io and socket.io-client 4.8.4, Node.js 24, puppeteer-core 25.16.0 and
Chrome 155. Everything was checked on 2026-10-24. The RTCPeerConnection methods used here, the SDP they produce and
the transceiver model are explained on Peer Connections and SDP;
ICE candidates, STUN and TURN are on ICE, STUN and TURN; the overall
picture is on WebRTC Architecture.
Why signalling is not standardised
The WebRTC specifications define the browser API (W3C WebRTC), the session-description exchange model (JSEP, RFC 9429)
and the media and transport protocols (ICE, DTLS-SRTP, SCTP), but not how two endpoints exchange the session
descriptions. JSEP only says that the application obtains a description from one peer, hands it to the other and applies
it with setRemoteDescription; how the bytes travel is up to the application. There are good reasons for that choice:
-
Applications already have a signalling system. A chat service has users, presence and permissions; a call centre has SIP; a federated network has XMPP. WebRTC plugs into whatever exists instead of forcing a second identity and routing layer.
-
The signalling channel is where the business rules live: who may join a room, how many people fit, who is moderator, what happens to a late joiner. None of that belongs in a browser API.
-
A single standard would have had to fit SIP, Jingle, proprietary SFU protocols and plain JSON at once.
The consequence is that every WebRTC application ships its own signalling protocol, and that most of the interesting bugs in a WebRTC application (lost candidates, stuck negotiation, ghost participants, replayed tokens) are signalling bugs rather than media bugs. The channel needs only a few properties:
-
Bidirectional and low latency, because candidates are trickled while the call is being set up. A WebSocket is the usual choice (see The WebSocket Protocol); Server-Sent Events plus an HTTP POST also works, and Socket.IO adds rooms and acknowledgements.
-
Ordered per sender, because a candidate must not overtake the description it belongs to. WebSocket and Socket.IO preserve message order on a connection; a naive pool of HTTP POSTs does not.
-
Authenticated and authorised, because the server decides who may talk to whom. Signalling is the access-control point of the call: media is end-to-end, but only peers that the signalling server introduces to each other will ever exchange it.
-
Not on the media path. The signalling server never sees audio or video, only short JSON messages, so one small Node.js process serves many rooms. Once the peer connection is established the signalling channel can even be closed, although Huddle keeps it open for later renegotiation (screen sharing, device changes) and presence.
Which scenarios use it
-
Huddle is the scenario of this page. In mesh mode (up to four participants, every pair of participants has its own peer connection) the signalling server relays descriptions and candidates between every pair, and the browser runs one perfect-negotiation link per remote peer. In SFU mode the same WebSocket carries the SFU’s own control protocol, and the client negotiates with the server instead of with each participant (see Topologies: Mesh, SFU and MCU, mediasoup and LiveKit).
-
Pixel Arena uses signalling only to set up a peer-to-server or peer-to-peer data channel, with the offer and answer travelling over the game’s lobby connection (see Data Channels).
-
Chatterbox does not need signalling, unless it adds calls. Then the Huddle messages below ride on the Chatterbox connection as an additional envelope type; the Chatterbox v1 wire protocol itself is defined on Message Design and Serialization and is not changed by this page.
The Huddle signalling protocol
The protocol is deliberately tiny. Every frame is one JSON text message with a type field. The server never inspects the
data of a signal message, it only checks that the target is in the same room as the sender and forwards it.
| Direction | Type | Payload |
|---|---|---|
client to server |
|
|
server to client |
|
|
server to client |
|
|
client to server |
|
|
server to client |
|
|
client to server |
|
|
server to client |
|
|
A complete offer travelling through the relay looks like this on the wire (the SDP is shortened; the full text of an offer is annotated on Peer Connections and SDP):
{"type":"signal","to":"p_7d4a3f21","data":{"description":{"type":"offer","sdp":"v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0 1\r\n..."}}}
{"type":"signal","from":"p_deafd35c","data":{"description":{"type":"offer","sdp":"v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0 1\r\n..."}}}
{"type":"signal","to":"p_7d4a3f21","data":{"candidate":{"candidate":"candidate:1 1 udp 2113937151 0a8d6b0c-0000-4000-8000-0123456789ab.local 54321 typ host","sdpMid":"0","sdpMLineIndex":0,"usernameFragment":"x1Yz"}}}
The first line is what Alice sends, the second what Bob receives, and the third a trickled candidate (an mDNS host
candidate, because browsers hide local addresses behind a .local name until the user grants media permission).
A browser serialises a candidate with candidate.toJSON(), which JSON.stringify calls implicitly; the receiving side
passes the parsed object straight to addIceCandidate.
A WebSocket signalling server with ws
The server has four jobs: authenticate the connection, put it in a room, tell the room who arrived or left, and relay
signal messages to one named peer. It uses ws 8.22.0 for the WebSocket protocol and Node’s http module for the
upgrade, which gives full control over what happens before the handshake completes. (The ws basics, upgrade handling
and Server-Sent Events are on WebSockets and Server-Sent Events.)
Authentication with a one-time ticket
The browser WebSocket constructor cannot set an Authorization header (see
The Browser WebSocket API), and cookies are sent automatically,
which is what makes Cross-Site WebSocket Hijacking possible. Huddle therefore uses a short-lived one-time ticket in
the query string, combined with an Origin allow-list:
-
The page, already logged in to the web application, calls an ordinary authenticated HTTPS endpoint and receives a ticket for the current user.
-
The page opens
wss://huddle.example.test/signal?ticket=...within 30 seconds. -
The server verifies the HMAC, the expiry and that the ticket was never used before, and only then upgrades the connection.
The ticket is an HMAC-SHA-256 over user.expiry.nonce with a server secret. The secret comes from the environment; the
default change-me below is a placeholder that must never reach production.
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.HUDDLE_TICKET_SECRET ?? 'change-me';
const TTL_MS = 30_000;
const used = new Map(); // nonce -> expiry, so a ticket works exactly once
function sign(body) {
return createHmac('sha256', SECRET).update(body).digest('base64url');
}
// Called by an authenticated HTTP endpoint (your login session decides who `user` is).
export function issueTicket(user) {
const body = `${user}.${Date.now() + TTL_MS}.${randomBytes(8).toString('hex')}`;
return `${body}.${sign(body)}`;
}
// Returns the user name, or null when the ticket is malformed, forged, expired or replayed.
export function redeemTicket(ticket) {
const parts = String(ticket ?? '').split('.');
if (parts.length !== 4) return null;
const [user, expires, nonce, mac] = parts;
const expected = Buffer.from(sign(`${user}.${expires}.${nonce}`));
const given = Buffer.from(mac);
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null;
const now = Date.now();
for (const [n, exp] of used) if (exp < now) used.delete(n);
if (Number(expires) < now || used.has(nonce)) return null;
used.set(nonce, Number(expires));
return user;
}
Source: Node.js docs: crypto.createHmac
A query-string ticket ends up in access logs and in proxies, which is why it is valid for 30 seconds and works only once:
a leaked ticket is useless after the first connection. Other options (a cookie plus an Origin check, the first message
after connecting, or a subprotocol hack) and their trade-offs are discussed on
Authentication and Authorization.
The server
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { WebSocketServer } from 'ws';
import { issueTicket, redeemTicket } from './ticket.js';
const PORT = Number(process.env.PORT ?? 40401);
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? `http://localhost:${PORT}`).split(',');
const MAX_ROOM_SIZE = 4; // mesh mode: every peer holds N-1 connections
const RELAY_DELAY_MS = Number(process.env.RELAY_DELAY_MS ?? 0); // test knob to widen the glare window
const rooms = new Map(); // room name -> Map(peerId -> client)
const files = { '/': ['index.html', 'text/html'], '/perfect-negotiation.js': ['perfect-negotiation.js', 'text/javascript'], '/huddle.js': ['huddle.js', 'text/javascript'] };
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/ticket') { // demo only: put this behind your real login session
const user = url.searchParams.get('user') ?? '';
if (!/^[a-z0-9-]{1,32}$/.test(user)) return res.writeHead(400).end();
return res.writeHead(200, { 'content-type': 'text/plain', 'cache-control': 'no-store' }).end(issueTicket(user));
}
const entry = files[url.pathname];
if (!entry) return res.writeHead(404).end();
res.writeHead(200, { 'content-type': entry[1] }).end(await readFile(new URL(`./public/${entry[0]}`, import.meta.url)));
});
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, 'http://localhost');
const origin = req.headers.origin;
const reject = (status, text) => { socket.write(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\n\r\n`); socket.destroy(); };
if (url.pathname !== '/signal') return reject(404, 'Not Found');
if (origin && !ALLOWED_ORIGINS.includes(origin)) return reject(403, 'Forbidden'); // blocks cross-site hijacking
const user = redeemTicket(url.searchParams.get('ticket'));
if (!user) return reject(401, 'Unauthorized');
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, user));
});
const send = (ws, msg) => ws.readyState === ws.OPEN && ws.send(JSON.stringify(msg));
function leave(client) {
const members = rooms.get(client.room);
if (!members) return;
members.delete(client.id);
for (const peer of members.values()) send(peer.ws, { type: 'peer-left', id: client.id });
if (members.size === 0) rooms.delete(client.room);
client.room = null;
}
wss.on('connection', (ws, user) => {
const client = { id: `p_${randomUUID().slice(0, 8)}`, name: user, ws, room: null, alive: true };
ws.on('pong', () => { client.alive = true; });
ws.on('message', (data, isBinary) => {
if (isBinary) return ws.close(1003, 'text only');
let msg;
try { msg = JSON.parse(data.toString()); } catch { return send(ws, { type: 'error', code: 'bad_request' }); }
if (msg.type === 'join') {
if (client.room) return send(ws, { type: 'error', code: 'conflict' });
if (!/^[a-z0-9-]{1,64}$/.test(msg.room ?? '')) return send(ws, { type: 'error', code: 'bad_request' });
const members = rooms.get(msg.room) ?? new Map();
if (members.size >= MAX_ROOM_SIZE) return send(ws, { type: 'error', code: 'room_full' });
const peers = [...members.values()].map((p) => ({ id: p.id, name: p.name }));
members.set(client.id, client);
rooms.set(msg.room, members);
client.room = msg.room;
send(ws, { type: 'joined', id: client.id, room: msg.room, peers });
for (const p of members.values()) if (p !== client) send(p.ws, { type: 'peer-joined', peer: { id: client.id, name: client.name } });
} else if (msg.type === 'leave') {
leave(client);
} else if (msg.type === 'signal') {
const target = rooms.get(client.room)?.get(msg.to); // only peers in the same room are reachable
if (!target) return send(ws, { type: 'error', code: 'not_found' });
const forward = () => send(target.ws, { type: 'signal', from: client.id, data: msg.data });
RELAY_DELAY_MS ? setTimeout(forward, RELAY_DELAY_MS) : forward(); // payload is opaque to the server
} else {
send(ws, { type: 'error', code: 'bad_request' });
}
});
ws.on('close', () => leave(client));
});
// Ping every 20 s; drop connections that missed the previous pong.
const heartbeat = setInterval(() => {
for (const members of rooms.values()) for (const c of members.values()) {
if (!c.alive) { c.ws.terminate(); continue; }
c.alive = false; c.ws.ping();
}
}, 20_000);
wss.on('close', () => clearInterval(heartbeat));
server.listen(PORT, () => console.log(`Huddle signalling (ws) on http://localhost:${PORT}`));
export { server, wss };
Points worth noting in the code:
-
noServer: trueplus theupgradeevent lets the server refuse a connection with a real HTTP status (401 for a bad ticket, 403 for a foreignOrigin, 404 for another path) before any WebSocket exists. Browsers cannot read the status of a failed handshake, they only see acloseevent with code 1006, so tests use a Node client to observe it. -
The
Origincheck lets requests without anOriginheader through, because non-browser clients do not send one and rely on the ticket. Browsers always send it, and a cross-site page cannot forge it. -
maxPayload: 64 * 1024closes a connection that sends a frame larger than a session description can reasonably be. A session description is normally a few kilobytes. -
signalis relayed only if the target is in the sender’s room. Otherwise any authenticated user could send offers to anyone whose id they learned. Ids are random, but they are never treated as secrets. -
The server stores no state about the peer connections: it is a router. That makes it easy to scale horizontally with a shared bus (see Scaling the signalling tier).
-
RELAY_DELAY_MSis a test knob that delays every relayed message, so that the two offers of the glare test below reliably cross in flight. It is0in production. -
Rooms are limited to four members because mesh mode needs a connection per pair: six connections for four peers and every participant uploads its video three times.
The perfect negotiation client
The problem: glare
A peer connection has a signalling state. It starts in stable; creating and applying a local offer moves it to
have-local-offer; applying a remote offer moves it to have-remote-offer; an answer returns it to stable. An offer
is only valid in stable. If both sides call setLocalDescription with an offer at the same time (because both added a
track, or both called restartIce, or both just joined a mesh room), each side then receives an offer while it is in
have-local-offer. This is glare, a term borrowed from telephony for two parties dialling each other simultaneously.
Without a rule, the session breaks: one or both calls to setRemoteDescription throw InvalidStateError, or both sides
roll back and offer again, forever.
Older applications avoided glare by fixing roles ("the caller always offers"), which breaks as soon as the callee wants to add a screen share or switch camera. Perfect negotiation is the pattern from the W3C specification and from MDN that makes negotiation symmetric: either side may start a negotiation at any time, and the code on both sides is identical except for one boolean.
The rules
-
One peer is polite and the other impolite. The roles must be complementary and stable for the life of the connection. In Huddle’s mesh the peer with the lexicographically greater server-assigned id is polite, so both sides compute opposite answers without extra messages. In a client and server design the server is usually impolite.
-
onnegotiationneededcreates an offer:await pc.setLocalDescription()with no argument chooses the right description type from the current state, and the result is sent. A flagmakingOfferis true during thisasyncwindow, because the signalling state staysstableuntilsetLocalDescriptionresolves, and a remote offer can arrive in between. -
When a remote offer arrives, there is an offer collision if the application is making an offer or the signalling state is not
stable. -
The impolite peer on a collision ignores the offer (
ignoreOffer = true) and waits for its own offer to be answered. -
The polite peer on a collision accepts the offer:
setRemoteDescription(offer)performs an implicit rollback of its own pending offer and applies the remote one, thensetLocalDescription()produces the answer. The polite peer’s own change is not lost: when the dust settles,negotiationneededfires again if the state is still stale, and it renegotiates. -
A candidate that belongs to an ignored offer fails in
addIceCandidate; the error is swallowed only whenignoreOfferis set, otherwise it is a real bug and is rethrown. -
The glare algorithm needs no timers, retries or tie-breaking messages, which is why the pattern deserves the word "perfect".
The client code
The class below owns one RTCPeerConnection for one remote participant. It knows nothing about WebSockets: it receives
a send callback and a handleSignal entry point, so the same code runs over the ws server, the Socket.IO server or
a SIP gateway. The counters in stats are for tests and diagnostics; they show exactly how often glare happened.
// One RTCPeerConnection plus the perfect negotiation state for a single remote peer.
export class PeerLink {
#pc; #send; #polite;
#makingOffer = false;
#ignoreOffer = false;
stats = { offersSent: 0, answersSent: 0, glare: 0, ignored: 0, rollbacks: 0 };
constructor({ polite, send, config, onTrack }) {
this.#polite = polite;
this.#send = send;
const pc = (this.#pc = new RTCPeerConnection(config));
pc.ontrack = (event) => onTrack?.(event);
pc.onicecandidate = ({ candidate }) => { if (candidate) this.#send({ candidate }); };
pc.onnegotiationneeded = async () => {
try {
this.#makingOffer = true;
await pc.setLocalDescription(); // picks offer or answer from signalingState
this.stats.offersSent++;
this.#send({ description: pc.localDescription });
} catch (err) {
console.error('negotiation failed', err);
} finally {
this.#makingOffer = false;
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') pc.restartIce(); // fires negotiationneeded again
};
}
get pc() { return this.#pc; }
addTracks(stream) {
for (const track of stream.getTracks()) this.#pc.addTrack(track, stream);
}
// Feed every {description} or {candidate} received from the signalling channel here.
async handleSignal({ description, candidate }) {
const pc = this.#pc;
try {
if (description) {
const collision = description.type === 'offer' && (this.#makingOffer || pc.signalingState !== 'stable');
this.#ignoreOffer = !this.#polite && collision;
if (collision) this.stats.glare++;
if (this.#ignoreOffer) { this.stats.ignored++; return; } // impolite peer wins the collision
if (collision) this.stats.rollbacks++; // polite peer: setRemoteDescription rolls back implicitly
await pc.setRemoteDescription(description);
if (description.type === 'offer') {
await pc.setLocalDescription();
this.stats.answersSent++;
this.#send({ description: pc.localDescription });
}
} else if (candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (err) {
if (!this.#ignoreOffer) throw err; // candidates of an ignored offer may fail
}
}
} catch (err) {
console.error('signal handling failed', err);
}
}
close() { this.#pc.close(); }
}
Source: MDN: Perfect negotiation
Why each line is the way it is:
-
setLocalDescription()without arguments is the "implicit" form defined in the current specification. It creates an offer instableand an answer inhave-remote-offer, so a single code path serves bothnegotiationneededand the answer to a remote offer. It also removes the oldcreateOfferthensetLocalDescription(offer)race, in which the state could change between the two calls. -
this.#makingOfferis cleared infinally, even whensetLocalDescriptionthrows, otherwise a failure would make the peer treat every future offer as a collision. -
collisionuses bothmakingOfferandsignalingState.signalingStatealone misses the window in whichsetLocalDescriptionis still running;makingOfferalone misses the case where a remote offer is being applied. -
ignoreOfferis stored in a field and not a local variable, because the candidates of the ignored offer arrive as separate messages and must be dropped too. -
Candidates with
candidate === null(end of gathering) are not sent. The remote side detects the end of candidates from thea=end-of-candidatesline or from connectivity checks (see ICE, STUN and TURN). -
restartIce()marks the transport for renegotiation and firesnegotiationneeded; the very same code then sends the new offer. This is how Huddle recovers from a network change (Wi-Fi to mobile data) without a special code path. -
addIceCandidateis queued by the browser behind a pendingsetRemoteDescription, so candidates that arrive immediately after their description do not need an application-level queue, provided the signalling channel delivers them in order.
Glare resolved, step by step
The diagram shows what happens in the test of this page: Alice and Bob, already connected to the signalling server, each add their camera and microphone tracks at the same instant. Alice is impolite and Bob is polite.
The result is one negotiated session built from the impolite peer’s offer. Bob’s rollback discards only his pending
offer, not the transceivers that his addTrack calls created: they are matched to the media sections of Alice’s offer,
so Bob’s camera and microphone travel in his answer and no second round is needed. Any further change on either side
triggers a new, again collision-safe, negotiation. The counters of
the real run, shown in the test section, match this picture: the impolite side ignored one offer, the polite side
rolled back one.
Wiring it together in the page
The Huddle class below connects a transport (WebSocket or Socket.IO) to the PeerLink instances. The two transport
adapters hide every difference between the servers: join(room) resolves with the assigned id and the current peers,
signal(to, data) sends, on(event, fn) subscribes and close() ends.
import { PeerLink } from './perfect-negotiation.js';
// Transport adapters expose the same tiny interface: join(room) -> {id, peers}, signal(to, data), on(event, fn), close().
export async function connectWebSocket(url, ticket) {
const ws = new WebSocket(`${url}?ticket=${encodeURIComponent(ticket)}`);
const handlers = new Map();
const pending = [];
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = () => reject(new Error('connect failed')); });
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.type === 'joined') return pending.shift()?.(msg);
handlers.get(msg.type)?.(msg);
};
return {
join: (room) => new Promise((resolve) => { pending.push(resolve); ws.send(JSON.stringify({ type: 'join', room })); }),
signal: (to, data) => ws.send(JSON.stringify({ type: 'signal', to, data })),
on: (event, fn) => handlers.set(event, fn),
close: () => ws.close(1000),
};
}
export async function connectSocketIo(io, ticket) {
const socket = io({ auth: { ticket }, transports: ['websocket'] });
await new Promise((resolve, reject) => { socket.on('connect', resolve); socket.on('connect_error', reject); });
return {
join: (room) => socket.emitWithAck('join', { room }),
signal: (to, data) => socket.emit('signal', { to, data }),
on: (event, fn) => socket.on(event, (payload) => fn(event === 'signal' ? payload : { peer: payload, id: payload.id })),
close: () => socket.disconnect(),
};
}
export class Huddle {
links = new Map(); // peerId -> PeerLink
streams = new Map(); // peerId -> MediaStream
#t; #stream; #config; #myId;
constructor({ transport, stream, config = { iceServers: [] } }) {
this.#t = transport; this.#stream = stream; this.#config = config;
}
async join(room) {
const t = this.#t;
t.on('peer-joined', ({ peer }) => this.#link(peer.id));
t.on('peer-left', ({ id }) => this.#drop(id));
t.on('signal', ({ from, data }) => this.#link(from).handleSignal(data));
const joined = await t.join(room);
this.#myId = joined.id;
for (const peer of joined.peers) this.#link(peer.id);
return joined;
}
#link(peerId) {
let link = this.links.get(peerId);
if (link) return link;
link = new PeerLink({
polite: this.#myId > peerId, // deterministic roles: both sides compute opposite answers
config: this.#config,
send: (data) => this.#t.signal(peerId, data),
onTrack: ({ streams: [stream] }) => this.streams.set(peerId, stream),
});
this.links.set(peerId, link);
link.addTracks(this.#stream); // triggers negotiationneeded on both sides: glare
return link;
}
#drop(peerId) {
this.links.get(peerId)?.close();
this.links.delete(peerId);
this.streams.delete(peerId);
}
// Inbound video proves media flows: bytes arrived and frames were decoded.
async mediaReport() {
const out = {};
for (const [peerId, link] of this.links) {
const report = await link.pc.getStats();
for (const s of report.values()) {
if (s.type === 'inbound-rtp' && s.kind === 'video') {
out[peerId] = { bytesReceived: s.bytesReceived, framesDecoded: s.framesDecoded ?? 0, negotiation: link.stats, state: link.pc.connectionState };
}
}
}
return out;
}
leave() { for (const id of [...this.links.keys()]) this.#drop(id); this.#t.close(); }
}
The role rule is the single line polite: this.#myId > peerId. Both sides evaluate the same comparison with the arguments
swapped, so exactly one of them is polite. The page that hosts it requests the camera and microphone first, obtains a
ticket, picks a transport from the query string and exposes two functions for the test:
<!doctype html>
<meta charset="utf-8">
<title>Huddle mesh test</title>
<body>
<script type="module">
import { Huddle, connectWebSocket, connectSocketIo } from '/huddle.js';
// ?kind=ws|socketio&user=alice&room=standup
const params = new URLSearchParams(location.search);
const user = params.get('user') ?? 'guest';
window.startHuddle = async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: 320, height: 240 } });
const ticket = await (await fetch(`/ticket?user=${user}`)).text();
let transport;
if (params.get('kind') === 'socketio') {
await import('/socket.io/socket.io.js'); // defines the global io()
transport = await connectSocketIo(window.io, ticket);
} else {
transport = await connectWebSocket(`ws://${location.host}/signal`, ticket);
}
window.huddle = new Huddle({ transport, stream });
return (await window.huddle.join(params.get('room') ?? 'standup')).id;
};
</script>
</body>
Source: MDN: MediaDevices.getUserMedia()
getUserMedia needs a secure context: https:// or http://localhost. That is why the local page is served from
localhost and not from a LAN address. Device labels, constraints and permissions are on
Media Capture and Devices.
A Socket.IO variant
Socket.IO already provides rooms, acknowledgements and middleware, so the server shrinks. The same ticket authenticates
the connection in io.use, a join event with an acknowledgement replaces the joined message, and socket.to(room)
broadcasts to everyone else in the room. The client code and PeerLink are unchanged, only connectSocketIo replaces
connectWebSocket.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { Server } from 'socket.io';
import { issueTicket, redeemTicket } from './ticket.js';
const PORT = Number(process.env.PORT ?? 40402);
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? `http://localhost:${PORT}`).split(',');
const MAX_ROOM_SIZE = 4;
const RELAY_DELAY_MS = Number(process.env.RELAY_DELAY_MS ?? 0);
const files = { '/': ['index.html', 'text/html'], '/perfect-negotiation.js': ['perfect-negotiation.js', 'text/javascript'], '/huddle.js': ['huddle.js', 'text/javascript'] };
const httpServer = createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/ticket') {
const user = url.searchParams.get('user') ?? '';
if (!/^[a-z0-9-]{1,32}$/.test(user)) return res.writeHead(400).end();
return res.writeHead(200, { 'content-type': 'text/plain', 'cache-control': 'no-store' }).end(issueTicket(user));
}
const entry = files[url.pathname];
if (!entry) return res.writeHead(404).end();
res.writeHead(200, { 'content-type': entry[1] }).end(await readFile(new URL(`./public/${entry[0]}`, import.meta.url)));
});
const io = new Server(httpServer, {
path: '/socket.io',
cors: { origin: ALLOWED_ORIGINS }, // CORS governs the polling transport
allowRequest: (req, callback) => { // the WebSocket transport needs an explicit Origin check
const origin = req.headers.origin;
callback(null, !origin || ALLOWED_ORIGINS.includes(origin));
},
maxHttpBufferSize: 64 * 1024,
});
io.use((socket, next) => { // runs once per connection, before 'connection'
const user = redeemTicket(socket.handshake.auth?.ticket);
if (!user) return next(new Error('unauthorized'));
socket.data.name = user;
next();
});
io.on('connection', (socket) => {
const peerSummary = (s) => ({ id: s.id, name: s.data.name });
socket.on('join', async ({ room } = {}, ack) => {
if (!/^[a-z0-9-]{1,64}$/.test(room ?? '') || typeof ack !== 'function') return;
const members = await io.in(room).fetchSockets();
if (members.length >= MAX_ROOM_SIZE) return ack({ ok: false, code: 'room_full' });
socket.data.room = room;
await socket.join(room);
ack({ ok: true, id: socket.id, room, peers: members.map(peerSummary) });
socket.to(room).emit('peer-joined', peerSummary(socket));
});
socket.on('signal', ({ to, data } = {}) => {
// Relay only inside the sender's room; socket ids are unguessable but never trusted.
if (!socket.data.room || !io.sockets.adapter.rooms.get(socket.data.room)?.has(to)) return;
const forward = () => io.to(to).emit('signal', { from: socket.id, data });
RELAY_DELAY_MS ? setTimeout(forward, RELAY_DELAY_MS) : forward();
});
socket.on('disconnecting', () => {
if (socket.data.room) socket.to(socket.data.room).emit('peer-left', { id: socket.id });
});
});
httpServer.listen(PORT, () => console.log(`Huddle signalling (Socket.IO) on http://localhost:${PORT}`));
export { httpServer, io };
Source: Socket.IO docs: Rooms
Differences from the ws server that matter for signalling:
| Aspect | ws server |
Socket.IO server |
|---|---|---|
Handshake rejection |
Plain HTTP 401 or 403 during the upgrade. |
|
Rooms |
Implemented by hand with a |
Built in: |
Acknowledgements |
You design |
|
Reconnection |
You write it (see Connection Lifecycle, Heartbeats and Reconnection). |
Automatic, with a new |
Scaling out |
Your own bus. |
Redis or Redis Streams adapter (see Socket.IO (Node.js)). |
Wire overhead |
One JSON message. |
Engine.IO and Socket.IO packet framing (see Socket.IO Protocol and Clients). |
Two details deserve attention. First, the client forces transports: ['websocket']: HTTP long-polling would require
sticky sessions behind a load balancer and adds latency to every candidate. Second, Socket.IO assigns a new socket.id
on every reconnection, so after a reconnect the polite and impolite roles of every link may flip. A reconnecting
client should be treated as a new participant: close the old links and create fresh ones, which keeps the role rule
valid.
Testing with two headless Chrome pages
The test starts the chosen signalling server, launches headless Chrome with a fake camera and microphone, opens two
pages (Alice and Bob) and has them join the same room. It then waits until both pages report inbound video with
bytesReceived > 0 and framesDecoded > 0 on the peer connection statistics, and finally checks that at least one
glare event happened. The relay runs with a 150 ms delay so that both offers are certainly in flight at the same time.
Everything is loopback: no STUN or TURN server is needed because both browsers are on the same machine and exchange
host candidates.
import { spawn } from 'node:child_process';
import puppeteer from 'puppeteer-core';
const CHROME = process.env.CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const [kind, port] = process.argv[2] === 'socketio' ? ['socketio', 40402] : ['ws', 40401];
const script = kind === 'socketio' ? 'signaling-socketio.js' : 'signaling-ws.js';
const server = spawn('node', [script], { env: { ...process.env, PORT: String(port), RELAY_DELAY_MS: '150' }, stdio: 'inherit' });
await new Promise((r) => setTimeout(r, 800));
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: true,
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--no-first-run', '--autoplay-policy=no-user-gesture-required'],
});
let failed = false;
try {
const open = async (user) => {
const page = await browser.newPage();
page.on('pageerror', (e) => console.log(`[${user}] pageerror`, e.message));
await page.goto(`http://localhost:${port}/?kind=${kind}&user=${user}`);
return page;
};
const alice = await open('alice');
const bob = await open('bob');
const aliceId = await alice.evaluate(() => window.startHuddle()); // alice joins first and waits
// Bob joins: both sides add tracks at the same moment and both create an offer.
const bobId = await bob.evaluate(() => window.startHuddle());
console.log({ aliceId, bobId });
const waitMedia = (page) => page.waitForFunction(async () => {
const r = await window.huddle.mediaReport();
return Object.values(r).length > 0 && Object.values(r).every((x) => x.bytesReceived > 0 && x.framesDecoded > 0);
}, { timeout: 20000, polling: 500 });
await Promise.all([waitMedia(alice), waitMedia(bob)]);
for (const [name, page] of [['alice', alice], ['bob', bob]]) {
console.log(name, JSON.stringify(await page.evaluate(() => window.huddle.mediaReport())));
}
const stats = (await Promise.all([alice, bob].map((p) => p.evaluate(() => [...window.huddle.links.values()].map((l) => l.stats))))).flat();
const total = stats.reduce((a, s) => ({ glare: a.glare + s.glare, ignored: a.ignored + s.ignored, rollbacks: a.rollbacks + s.rollbacks }), { glare: 0, ignored: 0, rollbacks: 0 });
console.log('glare totals', total);
if (total.glare === 0) { console.log('FAIL: no glare observed'); failed = true; }
} catch (err) {
console.error('FAIL', err); failed = true;
} finally {
await browser.close();
server.kill();
}
process.exit(failed ? 1 : 0);
The Chrome flags matter: --use-fake-device-for-media-stream replaces the camera with a generated test pattern and the
microphone with a beep, and --use-fake-ui-for-media-stream accepts the permission prompt automatically. The
dependencies and the commands to run both variants:
{
"name": "huddle-signaling",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start:ws": "node signaling-ws.js",
"start:socketio": "node signaling-socketio.js"
},
"dependencies": {
"socket.io": "4.8.4",
"ws": "8.22.0"
}
}
npm install puppeteer-core@25.16.0 socket.io-client@4.8.4 # test-only dependencies
node test-two-browsers.js ws # ws server on port 40401
node test-two-browsers.js socketio # Socket.IO server on port 40402
Source: Puppeteer guides: Installation
A successful run prints output like this (byte counts and frame counts vary, the glare counters do not):
{ aliceId: 'p_deafd35c', bobId: 'p_7d4a3f21' }
alice {"p_7d4a3f21":{"bytesReceived":6989,"framesDecoded":3,"negotiation":{"offersSent":1,"answersSent":1,"glare":1,"ignored":0,"rollbacks":1},"state":"connected"}}
bob {"p_deafd35c":{"bytesReceived":10974,"framesDecoded":4,"negotiation":{"offersSent":1,"answersSent":0,"glare":1,"ignored":1,"rollbacks":0},"state":"connected"}}
glare totals { glare: 2, ignored: 1, rollbacks: 1 }
Reading it: each side sent exactly one offer, so both collided (glare: 1 on each side). The impolite peer ignored the
remote offer and never answered; the polite peer performed one rollback and sent the only answer. Both peer
connections reached connected, and video frames were decoded on both ends, so the fake camera really crossed the
loopback.
The authentication rules are tested separately with a Node client, because browsers hide handshake statuses:
// test-auth.js (excerpt): each attempt resolves to 'open' or 'HTTP <status>'
const attempt = (query, origin) => new Promise((resolve) => {
const ws = new WebSocket(`ws://localhost:${port}/signal${query}`, { origin });
ws.on('open', () => { ws.close(); resolve('open'); });
ws.on('unexpected-response', (_req, res) => resolve(`HTTP ${res.statusCode}`));
ws.on('error', () => {});
});
Run against the server above it prints HTTP 401 without a ticket, HTTP 403 for Origin: https://evil.example,
open for a valid ticket, and HTTP 401 for both a replayed and a forged ticket.
More testing techniques (fake devices, Playwright, network throttling) are on Testing Real-Time Apps.
Common errors and pitfalls
-
InvalidStateError: Failed to set remote answer sdp: Called in wrong state: stable. An answer arrived after the offer was abandoned or answered twice. It usually means two code paths callsetRemoteDescriptionfor the same message, or an offer was resent after a reconnect without tracking its state. -
Both peers polite or both impolite. Both polite means both roll back and both answer, and the session never settles in a consistent state; both impolite means both ignore the other’s offer and nobody answers: the call hangs with no error. Derive the roles from something both sides know (ids, the server-assigned order of arrival) and log them.
-
Calling
createOfferandsetLocalDescription(offer)yourself inonnegotiationneeded. The state can change between the two calls. Use the no-argumentsetLocalDescription(). -
Candidates arriving before the description.
addIceCandidaterejects when there is no remote description at all. The signalling layer must preserve per-sender order; do not process messages from a thread pool or from several parallel HTTP POSTs. -
Forgetting to relay candidates for a renegotiation. Trickle ICE continues after the first connection (ICE restarts, new transports for added transceivers). Relay every
onicecandidatefor the lifetime of the peer connection. -
Creating the peer connection twice for one participant. A
peer-joinedevent and asignalthat arrives first can both try to create the link.Huddle.#linkis idempotent for exactly that reason. -
Leaking links. On
peer-leftclose theRTCPeerConnectionand remove its streams, otherwise the camera keeps uploading to a participant who left. -
Running the signalling server behind a proxy that closes idle connections. An idle room with nobody speaking still needs traffic; keep the WebSocket pinging (see Connection Lifecycle, Heartbeats and Reconnection).
-
Signalling works but media does not flow. That is an ICE problem (NAT, firewall, missing TURN), not a negotiation problem. Check
iceConnectionStateandchrome://webrtc-internals, then read ICE, STUN and TURN. -
getUserMediafails in the test. Without--use-fake-ui-for-media-streamthe permission prompt blocks headless Chrome; without a secure origin the API is undefined.
Security
-
Authenticate before upgrading, authorise on every message. A valid ticket only identifies the user; the server still checks that the user may join the requested room and that the target of a
signalis in the same room. -
Allow-list
Origin. Browsers do not apply the same-origin policy to WebSocket handshakes and send cookies automatically, so a malicious page can open a socket to your signalling server with the visitor’s cookie. With the ticket in the query string the cookie is not what authenticates, but the allow-list is still the defence that also protects cookie-authenticated variants. -
Treat relayed descriptions as untrusted input. The server forwards SDP it never parses, and the receiving browser parses it. Browsers are hardened against hostile SDP, but do not feed received SDP to your own regular expressions or to a server-side library without limits. Cap message size (
maxPayload) and rate. -
Do not trust sender fields. The
fromattribute is added by the server from the connection, never read from the client message. -
SDP and candidates reveal addresses. Candidates include IP addresses (public ones for
srflx,relay), so never broadcast them to the room: relay them only to the one target. mDNS host candidates hide local addresses until the user grants capture permission. -
Keep tickets short-lived and one-time, and keep TURN credentials ephemeral too; see ICE, STUN and TURN.
-
Signalling security is not media security. Media is encrypted with DTLS-SRTP, and the DTLS fingerprint inside the SDP is what authenticates the peer. If the signalling server is compromised it can substitute fingerprints and man-in-the-middle the call. End-to-end identity needs more than signalling (see End-to-End Encryption).
-
General WebSocket hardening (rate limits, message size, close codes) is on Security.
Scaling the signalling tier
One Node.js process handles tens of thousands of idle signalling sockets, since the traffic per participant is a few
kilobytes per negotiation. The state that matters is room membership, because two participants of one room can land
on different instances. There are two options: route a whole room to one instance (consistent hashing on the room name at
the load balancer), or share membership and relay through a bus such as Redis pub/sub. The Socket.IO variant gets the
second option from its Redis adapter; for ws the patterns are on
Scaling WebSockets. Media scaling (SFUs, TURN capacity) is a separate
problem covered on Scaling WebRTC and TURN.
Alternatives to a hand-written protocol
-
XMPP with Jingle. XMPP can be the signalling channel, with the descriptions expressed as Jingle stanzas: XEP-0166 (Jingle, Stable) defines the session lifecycle (
session-initiate,session-accept,transport-infofor candidates,session-terminate), XEP-0167 (Jingle RTP Sessions, Stable) the media description, XEP-0176 (Jingle ICE-UDP Transport Method, Stable) the candidates, and XEP-0343 (Signaling WebRTC datachannels in Jingle) the data channels. Browsers speak XMPP over a WebSocket (RFC 7395). A browser-side translation layer converts between SDP and Jingle; this is how Jitsi Meet talks to its conference focus. See XMPP Fundamentals and Jingle Calls and Jitsi. Jingle brings federation, presence and roster for free, at the price of a more complex stack. Glare in Jingle is handled bytie-breakerrors in the session protocol rather than by the perfect negotiation pattern. -
SIP over WebSocket. RFC 7118 defines
wsandwsstransports for the Session Initiation Protocol (RFC 3261), so a browser can register with a SIP proxy and exchange INVITE messages carrying the SDP offer and answer. Libraries such as JsSIP and SIP.js implement the browser side, and Kamailio, OpenSIPS, Asterisk and FreeSWITCH accept the transport. Choose it when the call must reach the telephone network or an existing PBX; the server side of that integration is on Telephony with Asterisk and SIP. -
SFU-defined protocols. LiveKit and mediasoup bring their own signalling (a WebSocket with protobuf or JSON messages in LiveKit, application-defined requests in mediasoup). The application still authenticates users and decides which room to join; the client library hides the negotiation. The mesh code of this page is what you replace.
-
WHIP and WHEP. For one-way ingestion and playback, the signalling is a single HTTP POST of the offer returning the answer; WHIP is RFC 9725 and WHEP is still an Internet-Draft (see WHIP, WHEP and Live Streaming).
Version and specification history
-
JSEP. The offer and answer model is specified by JSEP. RFC 9429 (2024) obsoletes RFC 8829 (2021), which had replaced the earlier drafts. Cite RFC 9429; the sections on rollback and on the signalling state machine are the ones this page depends on.
-
Implicit
setLocalDescription(). Calling it without an argument, and the implicit rollback performed bysetRemoteDescription(offer)while inhave-local-offer, are part of the current WebRTC 1.0 text and of JSEP, and current evergreen browsers implement them. They are what makes the compact form of perfect negotiation possible. Before them, the pattern needed explicitsetLocalDescription({type: 'rollback'})calls andcreateOfferplussetLocalDescription(offer)pairs. -
Perfect negotiation. The pattern is documented in the specification’s examples and in MDN’s article. It is the reference way to write the negotiation code, and the code of this page follows it.
-
ICE restarts and trickle.
restartIce()and the trickle ICE exchange are described in RFC 8840 (SDP offer and answer for trickle ICE) and RFC 8445 (ICE). -
adapter.js. Version 9 removed most shims and is optional; this page does not use it.