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:

  1. HTTPS?getUserMedia and WebRTC require a secure context.
  2. Signaling working? — Did offer/answer/ice messages actually reach the other peer?
  3. ICE candidates exchanged? — Check pc.localDescription includes candidates.
  4. STUN reachable?iceConnectionState stuck at "checking" = STUN/TURN problem.
  5. TURN configured? — Symmetric NAT requires TURN; STUN-only fails ~20% of connections.
  6. connectionState"failed" is terminal; "disconnected" may self-heal.
  7. Statsawait 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:

CauseFix
No TURN server configuredAdd a TURN server
Symmetric NAT on one/both sidesAdd TURN with TCP/TLS fallback
Firewall blocking UDP 3478Use turns: (TLS on port 443)
STUN server unreachableTry a different STUN server
ICE candidates not delivered via signalingDebug signaling channel
addIceCandidate called before setRemoteDescriptionBuffer 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-internals before 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

SymptomRoot CauseFix
getUserMedia throws NotAllowedErrorPermission deniedCheck HTTPS, check browser permission
InvalidStateError on setLocalDescriptionWrong stateCheck signalingState
Candidates not addedRace: remote desc not setBuffer candidates until after setRemoteDescription
ontrack never firesSender-only transceiverSet direction: 'sendrecv' or add recvonly transceiver
Video one-way onlyofferToReceiveVideo: false or wrong directionUse transceivers explicitly
ICE failed in corporate networkSymmetric NAT, firewallAdd TURN with turns: on 443
Safari no autoplay audioAutoplay policyRequire user tap before audioEl.play()
onnegotiationneeded loopNo guardCheck signalingState === 'stable'
Echo on remote sideLocal audio not mutedlocalVideo.muted = true
Bitrate stuck lowEncoder constraintsUse sender.setParameters to raise maxBitrate