WebRTC Cheatsheet
Signaling
Use this WebRTC reference while you build software engineering projects, review code for technical interview prep, or polish examples for a software engineer resume.
What Signaling Is
WebRTC has no built-in signaling. You provide a side-channel to exchange three types of messages:
| Message | Direction | Payload |
|---|---|---|
offer | Caller → Callee | RTCSessionDescriptionInit |
answer | Callee → Caller | RTCSessionDescriptionInit |
ice | Both → Both | RTCIceCandidateInit |
Any transport works: WebSocket, HTTP long-poll, SSE, XMPP, SMS. WebSocket is most common.
WebSocket Signaling Server (Node.js)
// server.js — minimal signaling relay import { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8080 }); const rooms = new Map(); // roomId → Set<WebSocket> wss.on('connection', (ws) => { let currentRoom = null; ws.on('message', (raw) => { const msg = JSON.parse(raw); if (msg.type === 'join') { currentRoom = msg.room; if (!rooms.has(currentRoom)) rooms.set(currentRoom, new Set()); rooms.get(currentRoom).add(ws); ws.send(JSON.stringify({ type: 'joined', peers: rooms.get(currentRoom).size - 1 })); return; } // Relay to all other peers in the room const peers = rooms.get(currentRoom) ?? new Set(); for (const peer of peers) { if (peer !== ws && peer.readyState === 1 /* OPEN */) { peer.send(raw.toString()); } } }); ws.on('close', () => { if (currentRoom) rooms.get(currentRoom)?.delete(ws); }); });
WebSocket Signaling Client
class Signaling extends EventTarget { constructor(url, room) { super(); this.ws = new WebSocket(url); this.ws.onopen = () => this.send({ type: 'join', room }); this.ws.onmessage = ({ data }) => { const msg = JSON.parse(data); this.dispatchEvent(new CustomEvent(msg.type, { detail: msg })); }; } send(msg) { this.ws.send(JSON.stringify(msg)); } on(type, handler) { this.addEventListener(type, e => handler(e.detail)); } } // Usage const signaling = new Signaling('wss://my-server/ws', 'room-42'); signaling.on('joined', ({ peers }) => { if (peers > 0) startCall(); // we are not the first — make an offer }); signaling.on('offer', async ({ sdp }) => { /* … */ }); signaling.on('answer', async ({ sdp }) => { /* … */ }); signaling.on('ice', async ({ candidate }) => { /* … */ });
Complete Two-Party Call Setup
let pc; function createPeerConnection() { pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); pc.onicecandidate = ({ candidate }) => { if (candidate) signaling.send({ type: 'ice', candidate: candidate.toJSON() }); }; pc.ontrack = ({ streams }) => { remoteVideo.srcObject = streams[0]; }; pc.onnegotiationneeded = async () => { if (pc.signalingState !== 'stable') return; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); signaling.send({ type: 'offer', sdp: pc.localDescription }); }; } // Caller: got joined event, peers > 0 async function startCall() { createPeerConnection(); const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); stream.getTracks().forEach(t => pc.addTrack(t, stream)); // triggers onnegotiationneeded } // Callee signaling.on('offer', async ({ sdp }) => { createPeerConnection(); const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); stream.getTracks().forEach(t => pc.addTrack(t, stream)); await pc.setRemoteDescription(sdp); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); signaling.send({ type: 'answer', sdp: pc.localDescription }); }); signaling.on('answer', async ({ sdp }) => { await pc.setRemoteDescription(sdp); }); signaling.on('ice', async ({ candidate }) => { if (candidate) await pc.addIceCandidate(candidate); });
ICE Candidate Buffering (Race Condition Fix)
Candidates can arrive before setRemoteDescription is called. Buffer them:
const pendingCandidates = []; let remoteDescriptionSet = false; signaling.on('ice', async ({ candidate }) => { if (remoteDescriptionSet) { await pc.addIceCandidate(candidate); } else { pendingCandidates.push(candidate); } }); async function applyRemoteDescription(desc) { await pc.setRemoteDescription(desc); remoteDescriptionSet = true; for (const c of pendingCandidates) { await pc.addIceCandidate(c); } pendingCandidates.length = 0; }
Signaling State Machine
new │ ▼ stable ◄──────────────────────────────────────────┐ │ │ createOffer setLocalDescription(answer) │ │ ▼ │ have-local-offer ──setRemoteDescription(answer)──► stable │ setRemoteDescription(offer) [callee] │ ▼ have-remote-offer ──createAnswer──► have-local-pranswer ──setLocalDescription──► stable
signalingState value | Meaning |
|---|---|
"stable" | No exchange in progress |
"have-local-offer" | Local offer set, awaiting remote answer |
"have-remote-offer" | Remote offer received, need to answer |
"have-local-pranswer" | Local provisional answer set |
"have-remote-pranswer" | Remote provisional answer received |
"closed" | Connection closed |
Perfect Negotiation Pattern
Handles simultaneous offer collisions elegantly:
let makingOffer = false; const polite = false; // one peer must be polite, the other impolite pc.onnegotiationneeded = async () => { try { makingOffer = true; await pc.setLocalDescription(); // browser creates offer automatically signaling.send({ type: 'offer', sdp: pc.localDescription }); } finally { makingOffer = false; } }; pc.onicecandidate = ({ candidate }) => { signaling.send({ type: 'ice', candidate }); }; signaling.on('message', async ({ type, sdp, candidate }) => { try { if (type === 'offer' || type === 'answer') { const offerCollision = type === 'offer' && (makingOffer || pc.signalingState !== 'stable'); const ignoreOffer = !polite && offerCollision; if (ignoreOffer) return; if (offerCollision) { // polite peer: rollback and accept incoming offer await Promise.all([ pc.setLocalDescription({ type: 'rollback' }), pc.setRemoteDescription(sdp), ]); } else { await pc.setRemoteDescription(sdp); } if (type === 'offer') { await pc.setLocalDescription(); signaling.send({ type: 'answer', sdp: pc.localDescription }); } } else if (type === 'ice' && candidate) { try { await pc.addIceCandidate(candidate); } catch (e) { if (!ignoreOffer) throw e; } } } catch (err) { console.error(err); } });
HTTP/Fetch-Based Signaling (Simple Polling)
// Caller posts offer, polls for answer async function fetchSignaling(roomId, offer) { await fetch(`/rooms/${roomId}/offer`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(offer), }); // Poll for answer while (true) { const res = await fetch(`/rooms/${roomId}/answer`); if (res.status === 200) { return res.json(); // RTCSessionDescriptionInit } await new Promise(r => setTimeout(r, 500)); } }
Server-Sent Events (SSE) Signaling
// Client subscribes to SSE, sends via POST const evtSource = new EventSource(`/signal?room=${roomId}`); evtSource.addEventListener('offer', e => handleOffer(JSON.parse(e.data))); evtSource.addEventListener('answer', e => handleAnswer(JSON.parse(e.data))); evtSource.addEventListener('candidate', e => handleCandidate(JSON.parse(e.data))); function send(msg) { fetch('/signal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room: roomId, ...msg }), }); }
Common Signaling Gotchas
- Never share your TURN credentials in client-side code; generate short-lived credentials server-side.
- Room IDs should be random and unguessable — anyone with the ID can join.
- setLocalDescription() with no argument (Unified Plan) lets the browser decide offer/answer automatically — prefer this over manually calling
createOffer. signalingState !== 'stable'guard prevents double-offer whenonnegotiationneededfires while an exchange is already in progress.- Reconnecting WebSocket — store and re-send unacknowledged messages; ICE candidates sent to a closed socket are silently lost.
- End-of-candidates signal — send
nullcandidate (or an emptycandidatestring) to indicate ICE gathering is complete when not using trickle.