WebRTC Cheatsheet

ICE, STUN, and TURN

Use this WebRTC reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

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.