WebRTC Cheatsheet

RTCPeerConnection

Use this WebRTC reference while you build software engineering projects, review code for technical interview prep, or polish examples for a software engineer resume.

Creating a Peer Connection

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    { urls: 'turn:turn.example.com', username: 'user', credential: 'pass' },
  ],
  iceTransportPolicy: 'all',      // 'all' | 'relay'
  bundlePolicy: 'max-bundle',     // 'balanced' | 'max-compat' | 'max-bundle'
  rtcpMuxPolicy: 'require',
  iceCandidatePoolSize: 0,
});

Adding Tracks and Streams

// Add a track (preferred — Unified Plan)
const sender = pc.addTrack(track, stream);

// Add all tracks from a stream
stream.getTracks().forEach(t => pc.addTrack(t, stream));

// Remove a track
pc.removeTrack(sender);

// Replace a track without renegotiation (same kind, same sender)
await sender.replaceTrack(newTrack);

Creating and Setting Descriptions

Caller (Offerer) Flow

// 1. Create offer
const offer = await pc.createOffer({
  offerToReceiveAudio: true,   // legacy — prefer transceivers
  offerToReceiveVideo: true,
  voiceActivityDetection: true,
  iceRestart: false,           // set true to force ICE restart
});

// 2. Set local description
await pc.setLocalDescription(offer);
// pc.localDescription now holds the offer

// 3. Send pc.localDescription to the remote peer via signaling
signaling.send({ type: 'offer', sdp: pc.localDescription });

Callee (Answerer) Flow

// 1. Receive offer and set as remote
await pc.setRemoteDescription({ type: 'offer', sdp: offerSdp });

// 2. Create and set answer
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);

// 3. Send back via signaling
signaling.send({ type: 'answer', sdp: pc.localDescription });

Caller Receives Answer

await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });

ICE Candidate Handling

// Send candidates as they are gathered
pc.onicecandidate = ({ candidate }) => {
  if (candidate) {
    signaling.send({ type: 'ice', candidate: candidate.toJSON() });
  }
  // candidate === null means gathering is complete
};

// Receive and add remote candidates
signaling.on('ice', async ({ candidate }) => {
  try {
    await pc.addIceCandidate(candidate); // accepts plain object or RTCIceCandidate
  } catch (e) {
    console.error('addIceCandidate failed', e);
  }
});

// Buffer candidates received before remote description is set
const candidateBuffer = [];

signaling.on('ice', async ({ candidate }) => {
  if (pc.remoteDescription) {
    await pc.addIceCandidate(candidate);
  } else {
    candidateBuffer.push(candidate);
  }
});

// After setRemoteDescription, flush the buffer
await pc.setRemoteDescription(desc);
for (const c of candidateBuffer) await pc.addIceCandidate(c);

Receiving Remote Tracks

pc.ontrack = (event) => {
  // event.streams[0] — the MediaStream the track belongs to
  // event.track      — the individual RTCRtpReceiver track
  const remoteVideo = document.getElementById('remoteVideo');
  if (remoteVideo.srcObject !== event.streams[0]) {
    remoteVideo.srcObject = event.streams[0];
  }
};

Transceivers — Full Control

Transceivers (Unified Plan) give you explicit control over send/receive direction.

// Add a transceiver
const transceiver = pc.addTransceiver('video', {
  direction: 'sendrecv',   // 'sendrecv' | 'sendonly' | 'recvonly' | 'inactive'
  streams: [localStream],
  sendEncodings: [{ maxBitrate: 1_000_000 }],
});

// Get all transceivers
const transceivers = pc.getTransceivers();
// [{ sender, receiver, direction, currentDirection, mid, stopped }, …]

// Change direction later
transceiver.direction = 'sendonly';

// Stop a transceiver (removes it from future SDP)
transceiver.stop();

Transceiver Properties

PropertyTypeDescription
senderRTCRtpSenderControls outgoing track
receiverRTCRtpReceiverControls incoming track
directionstringDesired direction (writable)
currentDirectionstringNegotiated direction (read-only)
midstring | nullMedia ID in SDP
stoppedbooleanWhether transceiver is stopped

RTCRtpSender — Encoding Parameters

const sender = pc.getSenders().find(s => s.track?.kind === 'video');
const params = sender.getParameters();

// Adjust bitrate
params.encodings[0].maxBitrate = 500_000; // 500 kbps
params.encodings[0].maxFramerate = 15;
params.encodings[0].scaleResolutionDownBy = 2; // half resolution

await sender.setParameters(params);

// Read current stats
const stats = await sender.getStats();
stats.forEach(report => console.log(report));

Simulcast

const transceiver = pc.addTransceiver(videoTrack, {
  direction: 'sendonly',
  sendEncodings: [
    { rid: 'high', maxBitrate: 1_200_000 },
    { rid: 'mid',  maxBitrate: 500_000,  scaleResolutionDownBy: 2 },
    { rid: 'low',  maxBitrate: 150_000,  scaleResolutionDownBy: 4 },
  ],
});

RTCRtpReceiver

const receiver = pc.getReceivers().find(r => r.track.kind === 'video');

// Capabilities the browser can receive
const caps = RTCRtpReceiver.getCapabilities('video');
// { codecs: [{mimeType, clockRate, …}], headerExtensions: […] }

// Stats
const stats = await receiver.getStats();

Statistics

// All stats for the connection
const statsReport = await pc.getStats();

statsReport.forEach(report => {
  if (report.type === 'inbound-rtp' && report.kind === 'video') {
    console.log('Packets received:', report.packetsReceived);
    console.log('Bytes received:',   report.bytesReceived);
    console.log('Packets lost:',     report.packetsLost);
    console.log('Jitter:',           report.jitter);
    console.log('Frames decoded:',   report.framesDecoded);
  }
  if (report.type === 'outbound-rtp' && report.kind === 'video') {
    console.log('Bitrate (bps):',    report.bytesSent);
    console.log('Frame rate:',       report.framesPerSecond);
  }
  if (report.type === 'candidate-pair' && report.nominated) {
    console.log('RTT:', report.currentRoundTripTime);
  }
});

Key RTCPeerConnection Events

EventWhen it fires
onnegotiationneededA new track/transceiver was added and re-offer is needed
onicecandidateA local ICE candidate is ready to send
onicecandidateerrorICE candidate gathering failed
ontrackRemote track arrived
onconnectionstatechangeconnectionState changed
oniceconnectionstatechangeiceConnectionState changed
onicegatheringstatechangeiceGatheringState changed
onsignalingstatechangesignalingState changed
ondatachannelRemote opened a data channel

Key RTCPeerConnection Properties

PropertyTypeDescription
localDescriptionRTCSessionDescriptionCurrent local SDP
remoteDescriptionRTCSessionDescriptionCurrent remote SDP
pendingLocalDescriptionRTCSessionDescription | nullOffer in-flight
signalingStatestringstable, have-local-offer, etc.
iceConnectionStatestringnew, checking, connected, completed, failed, disconnected, closed
iceGatheringStatestringnew, gathering, complete
connectionStatestringnew, connecting, connected, disconnected, failed, closed

Closing a Connection

// Stop all senders
pc.getSenders().forEach(sender => {
  sender.track?.stop();
  pc.removeTrack(sender);
});

// Close the connection
pc.close();
// After close(), all states are 'closed' and no events fire

Renegotiation (onnegotiationneeded)

// Fires when tracks added/removed require a new offer
pc.onnegotiationneeded = async () => {
  try {
    await pc.setLocalDescription(await pc.createOffer());
    signaling.send({ type: 'offer', sdp: pc.localDescription });
  } catch (err) {
    console.error('Renegotiation failed', err);
  }
};

onnegotiationneeded can fire multiple times. Use a flag or check signalingState === 'stable' before creating a new offer.

ICE Restart

// Force ICE restart (new credentials, new candidates) — useful after network change
const offer = await pc.createOffer({ iceRestart: true });
await pc.setLocalDescription(offer);
signaling.send({ type: 'offer', sdp: pc.localDescription });