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)
| Type | Description | Works across NAT? |
|---|---|---|
| host | Local IP/port on the machine | Same LAN only |
| srflx (server-reflexive) | Public IP/port via STUN | Most NAT types |
| prflx (peer-reflexive) | Discovered during connectivity checks | Yes |
| relay | Traffic routed through TURN server | Always |
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
| Scheme | Transport | Port (default) |
|---|---|---|
stun: | UDP | 3478 |
stuns: | UDP + DTLS | 5349 |
turn: | UDP or TCP | 3478 |
turn:…?transport=tcp | TCP | 3478 |
turns: | TCP + TLS | 5349 |
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
iceConnectionState | Meaning |
|---|---|
"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
iceGatheringState | Meaning |
|---|---|
"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
Popular Public TURN/STUN Providers
| Provider | Free Tier | Notes |
|---|---|---|
| Twilio Network Traversal Service | Pay-as-you-go | Most reliable; geo-distributed |
| Cloudflare Calls | Generous free tier | Integrated with CF Workers |
| Metered.ca | 50 GB/mo free | Open-source friendly |
| coturn (self-hosted) | Unlimited | You manage infra |
Google STUN (stun.l.google.com) | Free | STUN only, no TURN |
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
addresshidden — 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.