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
| Property | Type | Description |
|---|---|---|
sender | RTCRtpSender | Controls outgoing track |
receiver | RTCRtpReceiver | Controls incoming track |
direction | string | Desired direction (writable) |
currentDirection | string | Negotiated direction (read-only) |
mid | string | null | Media ID in SDP |
stopped | boolean | Whether 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
| Event | When it fires |
|---|---|
onnegotiationneeded | A new track/transceiver was added and re-offer is needed |
onicecandidate | A local ICE candidate is ready to send |
onicecandidateerror | ICE candidate gathering failed |
ontrack | Remote track arrived |
onconnectionstatechange | connectionState changed |
oniceconnectionstatechange | iceConnectionState changed |
onicegatheringstatechange | iceGatheringState changed |
onsignalingstatechange | signalingState changed |
ondatachannel | Remote opened a data channel |
Key RTCPeerConnection Properties
| Property | Type | Description |
|---|---|---|
localDescription | RTCSessionDescription | Current local SDP |
remoteDescription | RTCSessionDescription | Current remote SDP |
pendingLocalDescription | RTCSessionDescription | null | Offer in-flight |
signalingState | string | stable, have-local-offer, etc. |
iceConnectionState | string | new, checking, connected, completed, failed, disconnected, closed |
iceGatheringState | string | new, gathering, complete |
connectionState | string | new, 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); } };
onnegotiationneededcan fire multiple times. Use a flag or checksignalingState === '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 });