WebRTC Protocol Stack & Signaling Servers: Architecture, Implementation & Production Configs

Real-time communication at scale requires strict separation between the control plane (signaling) and the data plane (media transport). WebRTC’s architecture intentionally decouples these layers, routing session negotiation over standard web protocols while media streams traverse optimized, encrypted UDP paths. This guide is the engineering map for the entire stack: it details the exact protocol mechanics, the session lifecycle from offer to connected media, production-grade server configurations, and the state management patterns required to deploy resilient, low-latency applications β€” then links out to focused deep-dives for every layer you will eventually have to harden.

If you build interactive video, voice, or low-latency data products, you have already discovered that the hard problems are not β€œhow do I call getUserMedia” but β€œwhy does 8% of my traffic never connect,” β€œwhy does Safari renegotiate differently than Chrome,” and β€œwhere do I put the TURN servers.” The sections below answer those questions in order, from the wire format up to the observability tooling.

WebRTC control plane versus data plane Two peers exchange SDP and ICE candidates through a signaling server over the control plane, then establish a direct DTLS-SRTP media path over the data plane after ICE connectivity checks against STUN and TURN servers. Control plane β€” signaling (WebSocket / HTTPS) Peer A browser / app Signaling server rooms / routing Peer B browser / app SDP + ICE SDP + ICE Data plane β€” media (DTLS-SRTP over UDP) STUN β€” reflexive mapping UDP 3478 TURN β€” relay fallback 3478 / TLS 5349 / 443 direct P2P media (preferred)
Control plane carries SDP and ICE through the signaling server; the data plane establishes a direct encrypted media path, falling back to TURN relay only when ICE checks fail.

Core Protocol Architecture

WebRTC bypasses traditional HTTP request/response cycles by establishing direct peer-to-peer UDP sockets for media. The stack is a layered assembly of IETF protocols, each owning one responsibility, multiplexed over a single port pair once the session is up. Understanding which protocol does what β€” and on which port β€” is the prerequisite for every firewall rule, every diagnostic, and every server you will deploy.

Protocol Role Port / transport
SDP Declarative session contract: media, codecs, fingerprints Carried in signaling payload (no port of its own)
ICE Candidate gathering + connectivity checks STUN/TURN ports; checks over chosen UDP/TCP path
STUN Server-reflexive address discovery, keepalives UDP 3478 (TLS 5349)
TURN Relayed media when direct paths fail UDP/TCP 3478, TLS 5349, often 443; relay 49152–65535
DTLS Handshake + key exchange for media Same UDP socket as RTP (mux)
SRTP Encrypted media payload (AES-GCM / AES-128-CM) Same UDP socket as RTP
RTP / RTCP Media packetization + feedback (loss, jitter, RTT) Muxed on one port via rtcp-mux
SCTP Reliable/unreliable data channel framing Over DTLS on the same transport
WebRTC protocol layer stack Six stacked layers: the RTCPeerConnection application layer, RTP/RTCP and SCTP framing, SRTP payload encryption, the DTLS handshake, ICE connectivity, and UDP or TCP transport, each annotated with the port or property that matters in production. One 5-tuple after BUNDLE + rtcp-mux what it costs you operationally Application β€” RTCPeerConnection, tracks, channels one negotiation per bundle RTP / RTCP β€” media SCTP β€” data channels muxed on the same port SRTP β€” AES-GCM payload encryption no plaintext media, ever DTLS handshake β€” derives the SRTP keys pinned by a=fingerprint ICE β€” candidate pairs, connectivity checks STUN 3478 / TURN 5349 / 443 UDP preferred β€” TCP/TLS relay fallback relay range 49152–65535 SDP itself sits outside this stack β€” it is carried by your signaling transport, not by WebRTC.
Every layer below the application shares one UDP socket once BUNDLE and rtcp-mux are negotiated; SDP is the only part of the contract that travels outside the stack.

Three relationships dominate the data plane. First, RTP/RTCP packetization: RTP carries audio/video payloads with sequence numbers and timestamps, while RTCP provides out-of-band feedback that drives adaptive bitrate control and lip-sync β€” the same feedback loop explored in depth under Bandwidth Estimation & Congestion Control. Second, DTLS-SRTP encryption: media is never transmitted in plaintext; a DTLS handshake runs over the same UDP socket used for RTP and derives the SRTP keys, eliminating any external TLS termination and preventing middlebox inspection. The a=fingerprint line in the SDP is what binds that handshake to the peer you think you are talking to, which is why Verifying DTLS Fingerprints to Prevent MITM treats a compromised signaling channel as the real attack surface. Third, UDP-first transport with fallback: WebRTC prioritizes UDP for latency, and only when symmetric NATs or corporate firewalls block ephemeral UDP ports does it fall back to TCP/TLS via a relay, accepting increased head-of-line blocking.

Cross-browser implementations differ in default codec negotiation and UDP port allocation. Chrome typically opens sockets across a wide ephemeral range, while Firefox restricts to 49152–65535 on some platforms. Always pin explicit port ranges in firewall rules to prevent asymmetric routing failures, and assume nothing about defaults across engines β€” the divergences are catalogued in Cross-Browser WebRTC Debugging.

The multiplexing policies in the configuration above are not optional in production. bundlePolicy: 'max-bundle' collapses audio, video, and data onto a single 5-tuple, so the connection performs exactly one ICE negotiation and one DTLS handshake regardless of how many tracks it carries; without it, each media section can demand its own transport, multiplying connectivity-check time and the number of relay allocations you pay for. rtcpMuxPolicy: 'require' folds RTCP onto the same port as RTP, halving the ports a firewall must permit. iceCandidatePoolSize pre-gathers candidates before setLocalDescription, so the first offer already carries server-reflexive candidates rather than trickling them all afterward β€” a small but reliable Time-to-First-Frame win on networks where STUN round trips are slow.

// Production RTCPeerConnection configuration
const pcConfig = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },              // public STUN for reflexive discovery
    {
      urls: 'turns:turn.example.com:5349?transport=tcp',    // TLS relay for restrictive networks
      username: ephemeralUser,                              // HMAC time-limited credential
      credential: ephemeralPass                             // never a static password
    }
  ],
  iceTransportPolicy: 'all',     // 'relay' forces TURN-only for strict enterprise networks
  bundlePolicy: 'max-bundle',    // multiplexes all media over a single UDP socket
  rtcpMuxPolicy: 'require',      // enforces RTCP multiplexing (mandatory in modern browsers)
  iceCandidatePoolSize: 2        // pre-gathers candidates to shave TTFF
};

const peerConnection = new RTCPeerConnection(pcConfig);

DTLS Role Negotiation and Certificate Lifetime

The layer engineers most often skip is the one that decides who speaks first on the wire. Every offer carries a=setup:actpass, meaning β€œI will take either role”; the answerer must resolve it by choosing a=setup:active (it becomes the DTLS client and sends the ClientHello) or a=setup:passive (it waits). Browsers answering a browser offer always pick active. Native peers and media servers built on older stacks sometimes echo actpass back in the answer, which is invalid β€” and the symptom is deceptively clean: ICE reaches a nominated pair, iceConnectionState reads connected, and then nothing happens, because both endpoints are politely waiting for the other to open the handshake. On a subsequent offer, setup:actpass means β€œkeep the role we already agreed on”, so a renegotiation that silently flips the role without an ICE restart tears down a perfectly healthy DTLS association.

The retransmission schedule explains the timings you observe when a handshake is blackholed rather than refused. DTLS uses an exponential backoff starting at 1 s and doubling through 2, 4, 8, 16 and 32 s, so a single dropped ClientHello costs a full second of setup latency, and a UDP path that swallows handshake records entirely burns roughly a minute before the browser gives up. That is why a session stuck in connecting with a valid, RTT-reporting candidate pair is a DTLS problem, not an ICE problem β€” the discrimination procedure and the packet-level evidence are in Debugging DTLS Handshake Failures.

Certificates are per-RTCPeerConnection by default: the browser generates a fresh self-signed ECDSA P-256 key pair on construction, which costs a few milliseconds of main-thread work and produces a new a=fingerprint for every call. RTCPeerConnection.generateCertificate() lets you mint one certificate at app start, cache it, and pass it in pcConfig.certificates, so reconnect storms and mesh topologies stop paying that cost repeatedly. It also gives you a stable fingerprint to pin, which is the precondition for any out-of-band identity check. Modern Chrome and Firefox negotiate DTLS 1.2 with ECDHE key agreement and an AEAD cipher, deriving SRTP keys via the use_srtp extension rather than a separate key-exchange protocol; there is no knob to select a cipher suite from JavaScript, so interop failures here are always resolved on the server side of the connection.

Transport & Session Lifecycle

A WebRTC session moves through a deterministic sequence: signaling channel established β†’ offer created and applied locally β†’ offer delivered through the signaling server β†’ answer returned β†’ ICE candidates trickle in both directions β†’ connectivity checks succeed β†’ DTLS handshake completes β†’ SRTP media flows. The RTCPeerConnection exposes this as two coupled state machines β€” signalingState (stable, have-local-offer, have-remote-offer) and connectionState (new, connecting, connected, disconnected, failed, closed). Treating these as independent is the root of most production bugs.

Session establishment sequence Peer A sends an offer through the signaling server to Peer B, Peer B answers, both trickle ICE candidates, then the peers run STUN connectivity checks, complete a DTLS handshake and start SRTP media directly without the server. Peer A Signaling server Peer B t0 offer (SDP) after setLocalDescription +8 ms offer routed to the room +40 ms answer (SDP) +48 ms answer applied β€” signalingState stable continuous trickle ICE candidates β€” forwarded as gathered below this line the server is no longer on the path +120 ms STUN connectivity checks over candidate pairs +180 ms DTLS handshake β€” SRTP keys derived +220 ms SRTP media β€” connectionState connected
The signaling server carries only offer, answer and candidates; connectivity checks, the DTLS handshake and all media run peer to peer once the descriptions are applied.

The offer/answer model is strictly single-initiator: only one peer may drive negotiation at a time. Concurrent createOffer() calls without state guards corrupt the signaling state and throw InvalidStateError. When both sides offer simultaneously β€” common in mesh topologies and reconnection storms β€” you hit glare, resolved with the perfect-negotiation pattern that the Signaling State Machine Patterns guide implements in full.

ICE is the part of the lifecycle that fails most often and most opaquely. Interactive Connectivity Establishment queries the OS network stack and classifies each candidate:

ICE candidate types compared Matrix comparing host, server-reflexive, peer-reflexive and relay candidates by how each is discovered, the latency it adds to the media path, and the network conditions where it is the pair that finally validates. Candidate type Discovered via Latency added Wins when host local NIC enumeration none same LAN, no NAT in path srflx STUN binding on 3478 under 5 ms most consumer cone NATs prflx learned during checks under 5 ms port-restricted mappings relay TURN allocation 20–40 ms one-way symmetric NAT, UDP blocked Priority order host > srflx > prflx > relay β€” expect 8–20% of sessions to end on the bottom row.
ICE prefers the cheapest candidate that validates; the relay row is the guaranteed path you pay for in both bandwidth and one-way delay.

Trickle ICE streams candidates asynchronously as they are discovered rather than waiting for gathering to complete, cutting Time-to-First-Frame by 200–800 ms in typical conditions and by 2–4 seconds versus bulk gathering on multi-interface hosts. The trade-off analysis lives in ICE Candidate Trickle vs Bulk Gathering. The session description that carries all of this β€” m= lines, a=rtpmap codec entries, a=fingerprint DTLS hashes, a=group:BUNDLE semantics β€” is a declarative contract; mishandling it is the second most common failure class after ICE.

// Trickle ICE: forward each candidate the moment it is gathered, filter locally first
peerConnection.onicecandidate = (event) => {
  if (!event.candidate) return; // null candidate signals gathering complete

  const { address, type } = event.candidate;
  // exclude loopback and link-local interfaces that never route to a remote peer
  const isLoopback = /^(127\.|::1$|fe80:)/.test(address ?? '');
  if (type === 'host' && isLoopback) return;

  signalingSocket.send(JSON.stringify({
    type: 'candidate',
    candidate: event.candidate.candidate,
    sdpMid: event.candidate.sdpMid,            // associates candidate with its m-line
    sdpMLineIndex: event.candidate.sdpMLineIndex
  }));
};

Connectivity checks are STUN binding requests carried over the candidate pair itself, authenticated with the a=ice-ufrag and a=ice-pwd values from the SDP. That authentication is the hidden state in every ICE restart: a restart is nothing more than a new ufrag/pwd pair in a fresh offer, and any candidate that arrives carrying the previous credentials is discarded without a log line. If your signaling layer buffers candidates and flushes them after the restart offer has been applied, they will look like they were delivered and silently do nothing β€” always tag queued candidates with the ufrag generation they belong to and drop stale ones yourself.

The controlling agent (the offerer, in browser-to-browser sessions) is the only side that may nominate. It marks a pair with the USE-CANDIDATE attribute once that pair has validated in both directions, and media begins flowing on it. Because pairs are checked in priority order, the first pair to validate is usually not the last pair to be tried β€” ICE keeps checking lower-priority pairs in the background and will promote a better one mid-session, which is how a call that started on a relay can quietly move to a direct path 2–3 seconds in. Watch this in getStats: the transport report’s selectedCandidatePairId changes value while nothing else about the session does.

Once a pair is nominated, RFC 7675 consent freshness takes over. Each agent sends a STUN binding request on the selected pair roughly every 5 seconds, randomized so both ends do not synchronise, and declares consent revoked after 30 seconds without a response. Two practical consequences follow. First, that 5 s cadence doubles as the NAT keepalive, and it sits comfortably inside the sub-30 s mapping lifetime typical of carrier-grade NAT β€” which is exactly why media survives an idle mute on mobile at all. Second, the 30 s revocation window is why connectionState does not flip to failed the instant a laptop’s Wi-Fi drops: the browser is still waiting out the consent timer. If you only react to failed, users stare at a frozen frame for half a minute before your recovery code runs, so treat the earlier disconnected transition as the trigger for a timer of your own, as described in Handling Wi-Fi to Cellular Network Handover.

Production Configuration

The signaling server is yours to design β€” the W3C specification deliberately leaves it undefined. HTTP long-polling or MQTT can technically exchange SDP and ICE, but WebSocket is the production standard: persistent, full-duplex, and capable of sub-10 ms message delivery. A production server must enforce strict message serialization, room-based routing, heartbeat pings with exponential backoff, and schema validation before any payload is fanned out to room subscribers. Unhandled socket drops during SDP exchange leave orphaned peer connections and leak memory in SPA frameworks, so pair every reconnect with the state-resumption approach in Reconnecting Signaling Sockets Without Losing Session State rather than tearing the room down. The connection lifecycle and horizontal scaling are covered in WebSocket Signaling Implementation.

// Minimal room-routing signaling server (ws + uuid) with payload validation
const { WebSocketServer } = require('ws');
const { v4: uuidv4 } = require('uuid');

const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map(); // Map<roomId, Set<WebSocket>>

wss.on('connection', (ws) => {
  ws.clientId = uuidv4();
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; }); // heartbeat liveness flag

  ws.on('message', (raw) => {
    let msg;
    try { msg = JSON.parse(raw); }
    catch { return ws.send(JSON.stringify({ error: 'INVALID_JSON' })); }

    if (!msg.roomId || !msg.type) {            // reject unroutable payloads early
      return ws.send(JSON.stringify({ error: 'MISSING_FIELDS' }));
    }

    if (!rooms.has(msg.roomId)) rooms.set(msg.roomId, new Set());
    const room = rooms.get(msg.roomId);

    for (const peer of room) {                 // relay to peers, never echo to sender
      if (peer.readyState === WebSocket.OPEN && peer !== ws) {
        peer.send(JSON.stringify({ ...msg, senderId: ws.clientId }));
      }
    }
    room.add(ws);
  });

  ws.on('close', () => {                        // free room state to avoid leaks
    for (const [roomId, peers] of rooms) {
      peers.delete(ws);
      if (peers.size === 0) rooms.delete(roomId);
    }
  });
});

// Detect dead sockets every 30s β€” under CGNAT, mappings expire in well under 30s
const interval = setInterval(() => {
  for (const ws of wss.clients) {
    if (!ws.isAlive) { ws.terminate(); continue; }
    ws.isAlive = false;
    ws.ping();
  }
}, 30_000);
wss.on('close', () => clearInterval(interval));

The relay tier is the other half of production configuration. STUN servers resolve public IP/port mappings cheaply, but fail under symmetric NATs and firewalls that block UDP outright; TURN relays media as a fallback at the cost of server bandwidth. A hardened turnserver.conf enforces time-limited HMAC credentials, per-user bandwidth caps, and a TLS listener on a port that survives deep packet inspection β€” on the strictest enterprise networks that means Forcing TURN over TCP 443 on Locked-Down Networks, the only egress most proxies leave open.

# /etc/turnserver.conf - production coturn template
listening-ip=0.0.0.0
listening-port=3478                  # plain STUN/TURN
tls-listening-port=5349              # TLS-wrapped relay for DPI-heavy networks
external-ip=PUBLIC_IP/PRIVATE_IP     # required when behind a 1:1 NAT (cloud VMs)
lt-cred-mech                         # long-term credential mechanism (HMAC)
use-auth-secret                      # enables shared-secret time-limited tokens
static-auth-secret=ROTATING_SECRET   # rotate; clients derive HMAC usernames from it
stale-nonce                          # forces nonce refresh, blocks replay
fingerprint
realm=media.yourdomain.com
cert=/etc/ssl/certs/turn.pem
pkey=/etc/ssl/private/turn.key
min-port=49152                       # relay allocation range β€” open this in the firewall
max-port=65535

# per-user resource limits prevent a single client from saturating the relay
max-bps=5000000
user-quota=10
total-quota=1000

Sizing the Relay and Signaling Tiers

Relay capacity is a bandwidth problem long before it is a CPU problem, and the arithmetic is unforgiving because every relayed byte crosses the box twice β€” once inbound from the sender, once outbound to the receiver. A single 720p stream at 1.5 Mbps therefore consumes 3 Mbps of relay throughput, so a 1 Gbps interface tops out near 330 concurrent relayed streams at line rate and should be planned at 60–70% of that, call it 200–230, before queuing delay starts showing up as jitter on the receiving side. Forwarding cost per packet is small β€” the same ~1–3 ms order as an SFU forward, since coturn is copying rather than decoding β€” which means adding cores to a saturated relay changes nothing. The other hard ceiling is the port range: min-port=49152 through max-port=65535 yields 16,384 allocations per public IP, and each active client allocation consumes one. Past that point you add IP addresses, not machines. With 8–20% of sessions ending on a relay, 10,000 concurrent sessions implies 800–2,000 relayed streams, which is a multi-node, multi-IP deployment rather than a single VM β€” and the sessions that land there are disproportionately the symmetric-NAT and mobile ones covered in Traversing Symmetric NAT with TURN.

The signaling tier fails in the opposite way: it is trivially cheap per message and expensive per fan-out. Broadcasting one candidate to a room of n participants is n serialize-and-send operations on a single event loop, so a 50-participant room turns every trickled candidate into 50 writes, and a reconnect storm after a network blip multiplies that by every client re-offering at once. Sub-10 ms delivery holds only while the loop stays unblocked, so serialize each payload once and reuse the buffer across recipients rather than calling JSON.stringify per peer. Because room membership is process-local in the server above, two peers that land on different nodes behind a load balancer will never see each other’s offers β€” the fix is either connection affinity on the room ID or a shared bus, which is what Scaling WebSocket Signaling with Redis Pub/Sub sets up.

Section Deep-Dives

This guide is the hub; each subsection below routes to a focused reference covering one layer of the stack in production detail.

ICE Candidate Gathering & Filtering

Candidate gathering surfaces every interface β€” VPNs, virtual adapters, cellular, loopback β€” and many of them degrade connection stability if forwarded blindly. The ICE Candidate Gathering & Filtering guide covers interface policy, mDNS host obfuscation, trickle timing, and the filtering logic that keeps only routable candidates, including symmetric-NAT and CGNAT edge cases.

SDP Offer/Answer Lifecycle

The session description is a strict contract, and the most painful interop bugs are m-line ordering and BUNDLE-group mismatches across engines. The SDP Offer/Answer Lifecycle guide walks the create/set/exchange sequence, safe renegotiation, and why setCodecPreferences() beats regex SDP munging for codec ordering.

Signaling State Machine Patterns

Mirroring signalingState and connectionState correctly β€” and resolving glare deterministically β€” is what separates a demo from a product. The Signaling State Machine Patterns guide implements perfect negotiation, automated ICE-restart triggers, and clean teardown that survives Wi-Fi-to-cellular handoffs.

STUN Server Deployment Strategies

Where you place STUN resolvers directly affects connect latency. The STUN Server Deployment Strategies guide covers multi-region anycast deployment that cuts connect latency 40–60%, public-versus-self-hosted trade-offs, and binding-refresh tuning for mobile and CGNAT clients where mappings expire in under 30 s.

TURN Server Configuration & Auth

A misconfigured relay is either an open bandwidth bill or a connectivity black hole. The TURN Server Configuration & Auth guide hardens coturn for production: time-limited HMAC-SHA1 credentials, per-user quotas, TLS on 443 for firewall traversal, and relay port-range planning across 49152–65535.

WebSocket Signaling Implementation

The signaling channel must survive load spikes and node failures. The WebSocket Signaling Implementation guide covers socket lifecycle, heartbeats, schema validation, and horizontal scaling with Redis Pub/Sub so room state stays consistent across server instances.

Cross-Browser WebRTC Debugging

Chrome, Firefox, and Safari diverge on codec defaults, candidate gathering, and renegotiation behavior. The Cross-Browser WebRTC Debugging guide maps named per-engine deviations and shows how to read chrome://webrtc-internals dumps and Firefox about:webrtc traces side by side to isolate where a session diverges.

Data Channels & SCTP

Not every payload is audio or video β€” game state, file transfer, and metadata ride the SCTP-over-DTLS data channel. The Data Channels & SCTP guide covers reliable versus unreliable (maxRetransmits / ordered) configuration, buffering with bufferedAmountLowThreshold, and the latency trade-offs for real-time game synchronization.

DTLS-SRTP Security & Encryption

Media encryption is mandatory in WebRTC, but β€œencrypted” only means β€œencrypted to whoever your signaling channel said the peer was”. The DTLS-SRTP Security & Encryption guide covers the handshake itself, the certificate fingerprints that bind it to a session, cipher-suite selection, and what breaks when a middlebox or a stale certificate stalls the exchange. It also covers the layer above transport security: End-to-End Encryption with Insertable Streams for topologies where the media server must forward frames it is not allowed to read.

Connection Recovery & ICE Restart

Real sessions outlive the network path they started on: laptops sleep, phones roam from Wi-Fi to LTE, and CGNAT mappings expire in under 30 s. The Connection Recovery & ICE Restart guide defines the recovery contract β€” when to wait out a disconnected blip, when to fire createOffer({ iceRestart: true }), and how to bound retries at 3 with a 3–5 s fallback. Start with Disconnected vs Failed ICE States if your current code reacts to both states identically, which is the most common cause of needless reconnect storms.

Failure Modes & Anti-Patterns

// Deterministic ICE-restart with a bounded retry budget
class WebRTCStateMachine {
  constructor(pc, signalingSocket) {
    this.pc = pc;
    this.signalingSocket = signalingSocket;
    this.reconnectAttempts = 0;
    this.maxRetries = 3;                       // bounded β€” never loop forever
    this.pc.onconnectionstatechange = () => this.onState();
  }

  onState() {
    const state = this.pc.connectionState;
    if (state === 'connected') {
      this.reconnectAttempts = 0;             // reset budget on recovery
    } else if (state === 'disconnected' || state === 'failed') {
      this.handleFailure();
    }
  }

  async handleFailure() {
    if (this.reconnectAttempts >= this.maxRetries) return this.teardown();
    this.reconnectAttempts++;
    try {
      const offer = await this.pc.createOffer({ iceRestart: true }); // new ICE, same media
      await this.pc.setLocalDescription(offer);
      this.signalingSocket.send(JSON.stringify({ type: 'offer', sdp: offer.sdp }));
    } catch {
      this.teardown();
    }
  }

  teardown() {
    this.pc.close();
    this.signalingSocket.close();
    window.dispatchEvent(new CustomEvent('webrtc:failed'));
  }
}

Debugging & Observability

WebRTC fails quietly: a session reports connected while a relay drops 30% of packets, or ICE never leaves checking because a candidate type is filtered upstream. Three instruments cover almost every investigation.

getStats() polling. Poll pc.getStats() at 1 s intervals β€” higher frequencies add main-thread overhead without finer signal. Correlate three report types: inbound-rtp and outbound-rtp for loss and jitter, candidate-pair for the selected path and current RTT, and transport for availableOutgoingBitrate and bytes relayed. The pattern below isolates whether a degradation is the network or the chosen path.

// Identify the active candidate pair and its RTT β€” the single most useful stat
const stats = await pc.getStats();
let selectedPairId;

for (const report of stats.values()) {
  if (report.type === 'transport') selectedPairId = report.selectedCandidatePairId;
}
for (const report of stats.values()) {
  if (report.type === 'candidate-pair' && report.id === selectedPairId) {
    const local = stats.get(report.localCandidateId);
    console.log(
      `path=${local?.candidateType}`,            // host / srflx / relay β†’ is media relayed?
      `rtt=${(report.currentRoundTripTime * 1000).toFixed(0)}ms`,
      `bytesSent=${report.bytesSent}`
    );
  }
  if (report.type === 'inbound-rtp' && report.kind === 'video') {
    const total = (report.packetsReceived ?? 0) + (report.packetsLost ?? 0);
    const loss = total > 0 ? (report.packetsLost / total * 100).toFixed(2) : '0.00';
    console.log(`loss=${loss}% jitter=${report.jitter?.toFixed(3)}s`);
  }
}

chrome://webrtc-internals. Chrome’s built-in dump records every getStats sample, the full SDP offer/answer, every ICE candidate, and DTLS state, plotted over time. Confirm the transport-wide-cc extension is present in the negotiated SDP, verify the DTLS handshake completed, and watch which candidate pair won. Export the dump and read it systematically using Cross-Browser WebRTC Debugging when a session behaves differently in Chrome than elsewhere.

Firefox about:webrtc. Firefox’s equivalent surfaces the ICE candidate table and connectivity-check results that most directly explain why a path failed to establish. When ICE stalls in checking, the about:webrtc candidate-pair grid shows which pairs were tried and which were never reached β€” the fastest way to spot an upstream firewall or a filtered candidate type.

Two stat pairs deserve a permanent place in that polling loop because they separate layers no other signal separates. On the selected candidate-pair, the gap between requestsSent and responsesReceived is a live consent-freshness monitor: while the path is healthy the two track each other within one sample, and a growing divergence tells you the path is dying 20-odd seconds before the browser gives up on it β€” enough lead time to start an ICE restart while media is still flowing. Separately, compare iceConnectionState against connectionState. connectionState aggregates ICE and DTLS, so the combination of a connected ICE state with a failed or stalled aggregate state points at the handshake rather than the path, and saves you from debugging NAT traversal for a certificate problem. Chrome exposes both in the same webrtc-internals timeline, stacked, which makes the divergence obvious at a glance.

For all three, add structured server-side logging keyed on a session ID: record SDP-exchange timestamps, every signaling message, and ICE state transitions. Correlating client getStats traces with server signaling logs on a shared key is what isolates control-plane bugs from data-plane bugs in production.

In practice, build a small triage habit: when a session never reaches connected, the problem is almost always the control plane or ICE β€” check that the answer actually arrived and that candidate-pair checks ran. When a session reaches connected but media is poor, the problem is the data plane β€” read candidate-pair to confirm whether media is relayed through TURN (a relay local candidate type) and read inbound-rtp loss and jitter. When a session connects, runs, then drops, suspect a network handoff or an expired NAT mapping and confirm your ICE-restart path fired. Encoding each of these signals into a dashboard keyed on session ID turns what is normally a multi-hour reproduction effort into a single query.

Triage decision tree A symptom splits three ways: a session that never connects points at the control plane and ICE, a connected session with poor media points at the data plane, and a session that drops after running points at a network handover or an expired NAT mapping. Symptom reported start from behaviour, not logs Never reaches connected Connected but media is poor Runs, then drops Control plane or ICE did the answer arrive? did any pair leave checking? read about:webrtc pair grid Data plane candidate-pair type = relay? inbound-rtp loss and jitter encode time vs RTT Path lost mid-session Wi-Fi to cellular handover? NAT mapping expired under 30 s? did the ICE restart fire? Join client getStats to server signaling logs on one session ID
Route the symptom to a layer before reading any logs: each branch has a different first stat to pull, and all three converge on a session-ID join.

FAQ

Why is WebRTC signaling transport-agnostic, and why is WebSocket preferred? The W3C specification decouples signaling from media so you can integrate with whatever auth and transport your stack already uses. WebSocket wins in practice because it is persistent and full-duplex, delivering SDP and ICE candidates in sub-10 ms versus the round-trip overhead of polling β€” which matters because every signaling round trip is added directly to setup latency.

How do I handle an ICE restart without dropping the active media stream? Call createOffer({ iceRestart: true }) on the initiating peer, send the offer over signaling, and apply it remotely with setRemoteDescription. The existing RTP/SRTP session stays intact while ICE renegotiates a fresh transport path, so there is no media interruption. Bound restarts to a maximum of 3 attempts with a 3–5 s fallback before declaring the session failed.

When do I actually need TURN versus just STUN? STUN is enough whenever both peers can be reached at their server-reflexive addresses, which covers most consumer NATs. You need TURN for symmetric NATs, UDP-blocking firewalls, and CGNAT, where no direct pair ever validates. Budget for roughly 8–20% of sessions relaying through TURN, and deploy relays in the same regions as your STUN resolvers to keep relay latency low.

How do I keep a media path alive behind carrier-grade NAT? CGNAT mappings can expire in well under 30 s of inactivity. WebRTC sends STUN binding-refresh keepalives on the active path, but you must confirm they are not being filtered and that your TURN allocation lifetime exceeds the refresh interval, or established media will die silently mid-call.

How do I tell a network problem from a CPU problem? Read outbound-rtp stats first. If framesDropped and totalEncodeTime climb while inbound-rtp jitter and candidate-pair RTT stay flat, the bottleneck is the encoder, not the network β€” reducing bitrate will not help. The full decision procedure is in Bandwidth Estimation & Congestion Control.

What should iceCandidatePoolSize actually be set to? One or two. The pool pre-gathers candidates before you ever call setLocalDescription, so the first offer already carries server-reflexive addresses instead of trickling them afterwards, which is worth a couple of hundred milliseconds when STUN round trips are slow. The cost is that each pooled connection gathers against every configured ICE server, including TURN β€” so a pool of 10 opens 10 relay allocations that count against user-quota and may never be used. Set it to the number of peer connections you expect to open imminently, not to a large number β€œfor headroom”.

Why does the browser take about 30 seconds to report a failure after the network drops? Because consent freshness, not packet loss, is what declares a path dead. Each side probes the selected pair every 5 seconds and only revokes consent after 30 seconds of silence, so connectionState stays optimistic through the entire window. iceConnectionState usually reaches disconnected far earlier; treat that as your signal, start a 3–5 s timer, and trigger recovery from it rather than waiting for the terminal state.

Related: continue with ICE Candidate Gathering & Filtering and TURN Server Configuration & Auth for the connectivity tier, then move up the stack to Media Server Architecture: SFU & MCU once you outgrow mesh peer connections.