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

join

{"type":"join","room":"standup"}. The room name matches [a-z0-9-]{1,64}.

server to client

joined

{"type":"joined","id":"p_ab12cd34","room":"standup","peers":[{"id":"p_...","name":"alice"}]}: the id the server assigned to this connection and the participants already in the room.

server to client

peer-joined, peer-left

{"type":"peer-joined","peer":{"id":"p_...","name":"bob"}} and {"type":"peer-left","id":"p_..."}.

client to server

signal

{"type":"signal","to":"p_...","data":{...}} where data is {"description":{...}} or {"candidate":{...}}.

server to client

signal

{"type":"signal","from":"p_...","data":{...}}: the same data, now tagged with the sender.

client to server

leave

{"type":"leave"}. Closing the socket has the same effect.

server to client

error

{"type":"error","code":"room_full"}. Codes: bad_request, conflict, not_found, room_full.

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:

  1. The page, already logged in to the web application, calls an ordinary authenticated HTTPS endpoint and receives a ticket for the current user.

  2. The page opens wss://huddle.example.test/signal?ticket=... within 30 seconds.

  3. 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.

ticket.js
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;
}

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

signaling-ws.js
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: true plus the upgrade event lets the server refuse a connection with a real HTTP status (401 for a bad ticket, 403 for a foreign Origin, 404 for another path) before any WebSocket exists. Browsers cannot read the status of a failed handshake, they only see a close event with code 1006, so tests use a Node client to observe it.

  • The Origin check lets requests without an Origin header 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 * 1024 closes a connection that sends a frame larger than a session description can reasonably be. A session description is normally a few kilobytes.

  • signal is 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_MS is a test knob that delays every relayed message, so that the two offers of the glare test below reliably cross in flight. It is 0 in 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

  1. 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.

  2. onnegotiationneeded creates an offer: await pc.setLocalDescription() with no argument chooses the right description type from the current state, and the result is sent. A flag makingOffer is true during this async window, because the signalling state stays stable until setLocalDescription resolves, and a remote offer can arrive in between.

  3. When a remote offer arrives, there is an offer collision if the application is making an offer or the signalling state is not stable.

  4. The impolite peer on a collision ignores the offer (ignoreOffer = true) and waits for its own offer to be answered.

  5. 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, then setLocalDescription() produces the answer. The polite peer’s own change is not lost: when the dust settles, negotiationneeded fires again if the state is still stale, and it renegotiates.

  6. A candidate that belongs to an ignored offer fails in addIceCandidate; the error is swallowed only when ignoreOffer is set, otherwise it is a real bug and is rethrown.

  7. 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.

public/perfect-negotiation.js
// 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(); }
}

Why each line is the way it is:

  • setLocalDescription() without arguments is the "implicit" form defined in the current specification. It creates an offer in stable and an answer in have-remote-offer, so a single code path serves both negotiationneeded and the answer to a remote offer. It also removes the old createOffer then setLocalDescription(offer) race, in which the state could change between the two calls.

  • this.#makingOffer is cleared in finally, even when setLocalDescription throws, otherwise a failure would make the peer treat every future offer as a collision.

  • collision uses both makingOffer and signalingState. signalingState alone misses the window in which setLocalDescription is still running; makingOffer alone misses the case where a remote offer is being applied.

  • ignoreOffer is 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 the a=end-of-candidates line or from connectivity checks (see ICE, STUN and TURN).

  • restartIce() marks the transport for renegotiation and fires negotiationneeded; 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.

  • addIceCandidate is queued by the browser behind a pending setRemoteDescription, 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.

sequenceDiagram participant A as Alice (impolite) participant S as Signalling server participant B as Bob (polite) Note over A,B: both stable, both add tracks at the same moment A->>A: negotiationneeded, makingOffer = true B->>B: negotiationneeded, makingOffer = true A->>S: signal offer A B->>S: signal offer B S->>B: offer A S->>A: offer B Note over A: collision and impolite, ignoreOffer = true, offer B dropped Note over B: collision and polite, setRemoteDescription rolls back offer B B->>S: signal answer to offer A S->>A: answer Note over A: setRemoteDescription(answer), state stable A-->>B: ICE candidates trickle both ways Note over B: negotiationneeded fires again if B still has changes to send A-->>B: media flows over DTLS-SRTP

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.

public/huddle.js
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:

public/index.html
<!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>

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.

signaling-socketio.js
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 };

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.

io.use calls next(new Error('unauthorized')); the client receives connect_error. The allowRequest hook checks the Origin for the WebSocket transport, cors.origin for HTTP long-polling.

Rooms

Implemented by hand with a Map.

Built in: socket.join, socket.to(room), io.in(room).fetchSockets().

Acknowledgements

You design joined and error messages.

emitWithAck and callbacks.

Reconnection

You write it (see Connection Lifecycle, Heartbeats and Reconnection).

Automatic, with a new socket.id each time; Huddle must rejoin the room and renegotiate.

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.

test-two-browsers.js
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:

package.json
{
  "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

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 call setRemoteDescription for 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 createOffer and setLocalDescription(offer) yourself in onnegotiationneeded. The state can change between the two calls. Use the no-argument setLocalDescription().

  • Candidates arriving before the description. addIceCandidate rejects 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 onicecandidate for the lifetime of the peer connection.

  • Creating the peer connection twice for one participant. A peer-joined event and a signal that arrives first can both try to create the link. Huddle.#link is idempotent for exactly that reason.

  • Leaking links. On peer-left close the RTCPeerConnection and 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 iceConnectionState and chrome://webrtc-internals, then read ICE, STUN and TURN.

  • getUserMedia fails in the test. Without --use-fake-ui-for-media-stream the 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 signal is 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 from attribute 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-info for 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 by tie-break errors in the session protocol rather than by the perfect negotiation pattern.

  • SIP over WebSocket. RFC 7118 defines ws and wss transports 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 by setRemoteDescription(offer) while in have-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 explicit setLocalDescription({type: 'rollback'}) calls and createOffer plus setLocalDescription(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.