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:

MessageDirectionPayload
offerCaller → CalleeRTCSessionDescriptionInit
answerCallee → CallerRTCSessionDescriptionInit
iceBoth → BothRTCIceCandidateInit

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 valueMeaning
"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 when onnegotiationneeded fires 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 null candidate (or an empty candidate string) to indicate ICE gathering is complete when not using trickle.