WebRTC Cheatsheet
WebRTC Troubleshooting
Use this WebRTC reference while you build software engineering projects, review code for technical interview prep, or polish examples for a software engineer resume.
Diagnostic Checklist
When a WebRTC connection fails, work through this in order:
- HTTPS? —
getUserMediaand WebRTC require a secure context. - Signaling working? — Did offer/answer/ice messages actually reach the other peer?
- ICE candidates exchanged? — Check
pc.localDescriptionincludes candidates. - STUN reachable? —
iceConnectionStatestuck at"checking"= STUN/TURN problem. - TURN configured? — Symmetric NAT requires TURN; STUN-only fails ~20% of connections.
connectionState—"failed"is terminal;"disconnected"may self-heal.- Stats —
await pc.getStats()gives ground truth on packets, bitrate, RTT.
Reading getStats()
// Dump all stats to console const report = await pc.getStats(); report.forEach(stat => console.log(stat.type, stat)); // Targeted queries async function printConnectionStats(pc) { const report = await pc.getStats(); for (const stat of report.values()) { switch (stat.type) { case 'candidate-pair': if (stat.nominated) { console.log('Nominated pair:'); console.log(' RTT:', stat.currentRoundTripTime * 1000, 'ms'); console.log(' Bytes sent:', stat.bytesSent); console.log(' Bytes received:', stat.bytesReceived); console.log(' Available outgoing bandwidth:', stat.availableOutgoingBitrate / 1000, 'kbps'); } break; case 'inbound-rtp': console.log(`Inbound ${stat.kind}:`); console.log(' Packets lost:', stat.packetsLost); console.log(' Jitter:', stat.jitter * 1000, 'ms'); console.log(' Frames dropped:', stat.framesDropped); console.log(' Decode time:', stat.totalDecodeTime / stat.framesDecoded * 1000, 'ms/frame'); break; case 'outbound-rtp': console.log(`Outbound ${stat.kind}:`); console.log(' Bytes sent:', stat.bytesSent); console.log(' Retransmitted bytes:', stat.retransmittedBytesSent); console.log(' FPS:', stat.framesPerSecond); console.log(' QP (quality):', stat.qpSum / stat.framesEncoded); break; case 'local-candidate': console.log('Local candidate type:', stat.candidateType, stat.protocol); break; case 'remote-candidate': console.log('Remote candidate type:', stat.candidateType, stat.protocol); break; } } }
Common Failure Modes
iceConnectionState Stuck at "checking"
Most likely causes:
| Cause | Fix |
|---|---|
| No TURN server configured | Add a TURN server |
| Symmetric NAT on one/both sides | Add TURN with TCP/TLS fallback |
| Firewall blocking UDP 3478 | Use turns: (TLS on port 443) |
| STUN server unreachable | Try a different STUN server |
| ICE candidates not delivered via signaling | Debug signaling channel |
addIceCandidate called before setRemoteDescription | Buffer candidates |
// Diagnose: what candidate types were gathered? const report = await pc.getStats(); const localCandidates = [...report.values()].filter(r => r.type === 'local-candidate'); console.log(localCandidates.map(c => `${c.candidateType} ${c.protocol} ${c.address}:${c.port}`)); // If you only see 'host' candidates — STUN is not working // If you see no 'relay' candidates — TURN is not configured or not reachable
No Remote Video / Audio
// Check if ontrack fired pc.ontrack = (e) => { console.log('Got track:', e.track.kind, e.track.readyState); // If track.readyState is 'ended' immediately, the sender stopped it }; // Check that remote description was set console.log('Remote desc:', pc.remoteDescription); // Check transceiver directions pc.getTransceivers().forEach(t => { console.log(t.mid, 'direction:', t.direction, 'currentDirection:', t.currentDirection); // If currentDirection is 'inactive' or 'sendonly', remote is not sending });
connectionState === 'failed' Immediately
// Check signaling was completed correctly console.log('Local desc type:', pc.localDescription?.type); // 'offer' | 'answer' console.log('Remote desc type:', pc.remoteDescription?.type); // must be the opposite // Check ICE candidate count const report = await pc.getStats(); const pairs = [...report.values()].filter(r => r.type === 'candidate-pair'); console.log('Candidate pairs tried:', pairs.length); // 0 pairs = no candidates were exchanged
onnegotiationneeded Fires in a Loop
// Guard with makingOffer flag let makingOffer = false; pc.onnegotiationneeded = async () => { if (makingOffer || pc.signalingState !== 'stable') return; makingOffer = true; try { await pc.setLocalDescription(await pc.createOffer()); signaling.send({ type: 'offer', sdp: pc.localDescription }); } finally { makingOffer = false; } };
Audio Echo
// For local preview: always mute the local video element localVideo.muted = true; // Ensure echo cancellation is on in getUserMedia const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, }); // Do NOT route remote audio to a Web Audio node and then back into getUserMedia
Video Freezes / Blocky Artifacts
High packet loss or low bitrate. Diagnose with stats:
setInterval(async () => { const report = await pc.getStats(); for (const stat of report.values()) { if (stat.type === 'inbound-rtp' && stat.kind === 'video') { const lossRate = stat.packetsLost / (stat.packetsReceived + stat.packetsLost); console.log('Packet loss:', (lossRate * 100).toFixed(1), '%'); console.log('Jitter:', stat.jitter * 1000, 'ms'); // > 5% loss → severe quality degradation // > 30 ms jitter → consider jitter buffer tuning } } }, 2000);
Using chrome://webrtc-internals
Open chrome://webrtc-internals (Chrome) or about:webrtc (Firefox) while a connection is active:
- Graphs — real-time bitrate, packet loss, RTT, jitter.
- ICE candidates — full list of gathered and used candidates.
- SDP — full offer/answer SDP for inspection.
- Event timeline — every state change with timestamp.
Create Dump— export all stats as JSON for offline analysis.
Always check
webrtc-internalsbefore guessing at the failure cause.
STUN/TURN Reachability Test
// Quick test: can we reach the STUN server and get a reflexive candidate? async function testStun(stunUrl) { return new Promise((resolve, reject) => { const pc = new RTCPeerConnection({ iceServers: [{ urls: stunUrl }] }); pc.createDataChannel('test'); const candidates = []; pc.onicecandidate = ({ candidate }) => { if (!candidate) { pc.close(); const hasSrflx = candidates.some(c => c.type === 'srflx'); resolve({ reachable: hasSrflx, candidates }); } else { candidates.push(candidate); } }; pc.onicecandidateerror = (e) => reject(new Error(`${e.errorCode}: ${e.errorText}`)); pc.createOffer().then(o => pc.setLocalDescription(o)); setTimeout(() => { pc.close(); reject(new Error('Timeout')); }, 5000); }); } const result = await testStun('stun:stun.l.google.com:19302'); console.log('STUN reachable:', result.reachable);
SDP Inspection Helpers
// Print human-readable SDP sections function parseSdpMedia(sdp) { return sdp.split('\nm=').slice(1).map(section => { const lines = section.split('\n'); const kind = lines[0].split(' ')[0]; const codecs = lines .filter(l => l.startsWith('a=rtpmap')) .map(l => l.replace('a=rtpmap:', '')); return { kind, codecs }; }); } console.log(parseSdpMedia(pc.localDescription.sdp));
Bandwidth Estimation and Congestion Control
// Force a specific maximum bitrate via SDP munging (not recommended but sometimes needed) function setMaxBitrate(sdp, bitrateBps) { return sdp.replace(/b=AS:\d+/g, `b=AS:${Math.floor(bitrateBps / 1000)}`); } // Prefer: use RTCRtpSender.setParameters const sender = pc.getSenders().find(s => s.track?.kind === 'video'); const params = sender.getParameters(); params.encodings[0].maxBitrate = 500_000; // 500 kbps hard cap await sender.setParameters(params);
Common Gotchas Summary
| Symptom | Root Cause | Fix |
|---|---|---|
getUserMedia throws NotAllowedError | Permission denied | Check HTTPS, check browser permission |
InvalidStateError on setLocalDescription | Wrong state | Check signalingState |
| Candidates not added | Race: remote desc not set | Buffer candidates until after setRemoteDescription |
ontrack never fires | Sender-only transceiver | Set direction: 'sendrecv' or add recvonly transceiver |
| Video one-way only | offerToReceiveVideo: false or wrong direction | Use transceivers explicitly |
ICE failed in corporate network | Symmetric NAT, firewall | Add TURN with turns: on 443 |
| Safari no autoplay audio | Autoplay policy | Require user tap before audioEl.play() |
onnegotiationneeded loop | No guard | Check signalingState === 'stable' |
| Echo on remote side | Local audio not muted | localVideo.muted = true |
| Bitrate stuck low | Encoder constraints | Use sender.setParameters to raise maxBitrate |