WebRTC Cheatsheet

ICE, STUN, and TURN

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

ICE Overview

ICE (Interactive Connectivity Establishment) is the algorithm that finds a working network path between two peers. It gathers candidates, prioritizes them, and tries them in order.

Candidate Types (priority order)

TypeDescriptionWorks across NAT?
hostLocal IP/port on the machineSame LAN only
srflx (server-reflexive)Public IP/port via STUNMost NAT types
prflx (peer-reflexive)Discovered during connectivity checksYes
relayTraffic routed through TURN serverAlways

STUN — Session Traversal Utilities for NAT

STUN tells a peer its public IP and port (server-reflexive candidate) without relaying traffic.

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },          // Google public STUN
    { urls: 'stun:stun1.l.google.com:19302' },
    { urls: 'stun:stun.cloudflare.com:3478' },          // Cloudflare public STUN
    { urls: 'stun:stun.stunprotocol.org:3478' },
  ],
});

STUN works for ~80 % of connections (full-cone, address-restricted, port-restricted NAT). Symmetric NAT requires TURN.

TURN — Traversal Using Relays around NAT

TURN relays all media through a server — always works but adds latency and bandwidth cost.

const pc = new RTCPeerConnection({
  iceServers: [
    {
      urls: [
        'turn:turn.example.com:3478',           // UDP
        'turn:turn.example.com:3478?transport=tcp', // TCP fallback
        'turns:turn.example.com:5349',          // TLS (firewalls blocking UDP)
      ],
      username: 'user123',
      credential: 'secret456',
    },
  ],
});

TURN URL Schemes

SchemeTransportPort (default)
stun:UDP3478
stuns:UDP + DTLS5349
turn:UDP or TCP3478
turn:…?transport=tcpTCP3478
turns:TCP + TLS5349

Short-Lived TURN Credentials (Server-Side)

Never embed long-lived TURN credentials in client JS. Generate time-limited credentials:

// Server (Node.js) — HMAC-based credential generation
import crypto from 'crypto';

function generateTurnCredentials(username, secret, ttlSeconds = 3600) {
  const expires = Math.floor(Date.now() / 1000) + ttlSeconds;
  const tempUser = `${expires}:${username}`;
  const credential = crypto
    .createHmac('sha1', secret)
    .update(tempUser)
    .digest('base64');
  return { username: tempUser, credential, expires };
}

// Client fetches from your API
const res = await fetch('/api/turn-credentials');
const { username, credential } = await res.json();

const pc = new RTCPeerConnection({
  iceServers: [{ urls: 'turn:turn.example.com', username, credential }],
});

ICE Transport Policy

// Default: try all candidate types
const pc = new RTCPeerConnection({ iceTransportPolicy: 'all' });

// Force relay through TURN (ensures media always goes through your server)
const pc = new RTCPeerConnection({ iceTransportPolicy: 'relay' });

Use 'relay' to enforce routing through your infrastructure (compliance, recording, content moderation). Never use it as a debugging shortcut — it hides connection issues.

ICE Candidate Pool

Pre-gather candidates before setLocalDescription to reduce setup latency:

const pc = new RTCPeerConnection({
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
  iceCandidatePoolSize: 10, // gather 10 candidates in advance
});

Monitoring ICE Progress

pc.onicecandidate = ({ candidate }) => {
  if (!candidate) {
    console.log('ICE gathering complete');
    return;
  }
  console.log('Candidate type:', candidate.type);      // 'host'|'srflx'|'relay'
  console.log('Candidate address:', candidate.address);
  console.log('Candidate port:', candidate.port);
  console.log('Protocol:', candidate.protocol);        // 'udp'|'tcp'
  console.log('Priority:', candidate.priority);
  console.log('Full candidate:', candidate.candidate); // SDP line
};

pc.onicecandidateerror = (event) => {
  console.error('ICE error', event.errorCode, event.errorText, event.url);
  // Common errors:
  // 701 — STUN server not reachable
  // 702 — STUN server returned error
  // 703 — STUN server: bad response
  // 704 — TURN server returned error
};

pc.oniceconnectionstatechange = () => {
  console.log('ICE state:', pc.iceConnectionState);
};

pc.onicegatheringstatechange = () => {
  console.log('Gathering state:', pc.iceGatheringState);
};

ICE Connection States

iceConnectionStateMeaning
"new"Not started
"checking"Sending STUN checks
"connected"At least one candidate pair works
"completed"All checks done; best pair selected
"failed"No working pair found
"disconnected"Connection dropped (may recover)
"closed"Connection closed

ICE Gathering States

iceGatheringStateMeaning
"new"Not started
"gathering"Actively collecting candidates
"complete"All candidates gathered

Reading the Chosen Candidate Pair

// After connection is established
const statsReport = await pc.getStats();
statsReport.forEach(report => {
  if (report.type === 'candidate-pair' && report.nominated && report.state === 'succeeded') {
    console.log('RTT:', report.currentRoundTripTime * 1000, 'ms');
    console.log('Bytes sent:', report.bytesSent);
    console.log('Bytes received:', report.bytesReceived);

    // Look up the local and remote candidate details
    const local  = statsReport.get(report.localCandidateId);
    const remote = statsReport.get(report.remoteCandidateId);
    console.log('Local type:', local?.candidateType);   // 'host'|'srflx'|'relay'
    console.log('Remote type:', remote?.candidateType);
    console.log('Protocol:', local?.protocol);           // 'udp'|'tcp'
  }
});

ICE Restart

Use when the connection drops (network change, sleep/wake):

pc.oniceconnectionstatechange = async () => {
  if (pc.iceConnectionState === 'failed') {
    // Attempt ICE restart
    const offer = await pc.createOffer({ iceRestart: true });
    await pc.setLocalDescription(offer);
    signaling.send({ type: 'offer', sdp: pc.localDescription });
  }
};

Self-Hosted TURN (coturn)

# Install coturn
apt install coturn

# /etc/turnserver.conf — minimal config
listening-port=3478
tls-listening-port=5349
fingerprint
lt-cred-mech
use-auth-secret
static-auth-secret=YOUR_SECRET_HERE
realm=turn.example.com
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
log-file=/var/log/turnserver.log

Common ICE Gotchas

  • Symmetric NAT (often corporate) blocks STUN-only connections — always configure TURN as a fallback.
  • iceConnectionState === 'disconnected' is transient — don't close the connection immediately; wait for 'failed' before restarting.
  • UDP blocked on port 3478 — configure turns: (TLS on 443) as a fallback; port 443 is almost never blocked.
  • TURN with iceTransportPolicy: 'all' — TURN is only used when no direct path works; if you need guaranteed relay, use 'relay'.
  • Candidates with address hidden — browsers may anonymize host candidates via mDNS (xxxxxxxx.local); this is normal and resolves during ICE checks.
  • Multiple TURN servers — only use 2–3; too many cause excessive candidates and slow gathering.