Debugging WebRTC Connections: A Practical Toolkit

A WebRTC call that refuses to connect rarely tells you why. The video element stays black, the data channel never opens, and the console shows an iceConnectionState of failed with no further detail. The instinct is to start changing code, but most dead connections fail at one specific layer, and the fastest path to a fix is identifying that layer before touching anything.

What follows is the workflow I actually use on production calls: a triage order that narrows the failure to signaling, ICE, DTLS, or media, then the browser tools and command-line checks that confirm the diagnosis. The goal throughout is to stop guessing and start reading the evidence the stack already hands you.

Triage: which layer actually failed?

A WebRTC session is established in a fixed sequence, and each stage depends on the one before it. Work through them in order. The first stage that never completes is almost always your real bug.

WebRTC connection triage sequence Work the connection sequence in order: signaling, ICE candidate gathering, connectivity checks, the DTLS security handshake, and finally media flow. The first stage that never completes points to the real fault — for example, no srflx or relay candidate means STUN/TURN is unreachable, and failed connectivity checks usually mean UDP is blocked and a TURN relay is required. 1 · Signaling — offer & answer applied? if not → your WebSocket / SDP transport 2 · ICE — host / srflx / relay gathered? if not → STUN/TURN unreachable or bad creds 3 · Connectivity — a candidate pair works? if not → UDP blocked; you need a TURN relay 4 · Security — DTLS handshake done? if not → cert, clock, or DTLS role mismatch 5 · Media — getStats() shows bytes moving? yes → the connection is healthy
Figure 1 — The connection sequence as a triage ladder. Find the first rung that never completes; the note on the right is where to look.
  1. Signaling. The offer and answer have to reach the other peer over your own transport (usually WebSocket). If setRemoteDescription is never called on one side, nothing else can happen. Symptom: signalingState is stuck at have-local-offer and you see no ICE candidates arriving from the remote peer.
  2. ICE. Both peers gather candidates and probe pairs to find a working network path. Symptom: candidates are exchanged, but iceConnectionState walks checking → failed and never reaches connected.
  3. DTLS. Once a candidate pair connects, the peers run a DTLS handshake to derive media keys. Symptom: ICE reaches connected, but connectionState stalls at connecting and never becomes connected.
  4. Media. Everything connected, but you see or hear nothing. Symptom: stats show an active candidate pair and the DTLS transport is connected, yet bytesReceived stays at zero or one direction is silent.

The single most useful distinction: if iceConnectionState reaches connected but connectionState does not, your network path is fine and the problem is DTLS or above. If ICE never connects, stop looking at media code entirely.

chrome://webrtc-internals, end to end

Open chrome://webrtc-internals in a separate tab before you start the call, since it only records peer connections created while it is open. Each RTCPeerConnection gets its own collapsible entry. Inside, the panels worth knowing are:

  • The event log at the top lists every state transition with timestamps: setLocalDescription, icecandidate events, iceconnectionstatechange, and so on. This is your timeline. A failure that happens 30 seconds in (a consent-freshness timeout) reads very differently from one that fails in the first second (no candidate pair ever formed).
  • The ICE candidate grid shows every local and remote candidate plus the candidate pairs being probed. Each pair has a state: frozen, waiting, in-progress, succeeded, or failed. A healthy connection has exactly one succeeded pair marked as nominated. If every pair shows failed, no path exists between the two sides.
  • The stats graphs render live getStats() output. The most useful are the candidate-pair RTT graph, the inbound/outbound bitrate graphs, and the packetsLost counters. A bitrate graph that climbs and then flatlines at zero tells you exactly when media stopped.

To capture a session for later analysis or a bug report, click Create Dump at the top of the page and download the JSON. It contains the full event log and a time series of every stat, which you can replay or hand to a colleague who was not at the keyboard when it broke.

Firefox: about:webrtc

Firefox exposes the same information at about:webrtc. The layout differs but the concepts map directly: there is a per-connection section with the offer and answer SDP, an ICE stats table listing each candidate pair and its nominated state, and an RTP stats table for inbound and outbound streams. Use the Save Page button to dump everything to an HTML file. When a call works in one browser but not the other, comparing the candidate tables side by side usually reveals which side failed to gather a usable candidate type.

Reading ICE candidates

Every candidate has a type, and the set of types each peer gathers tells you a lot about the network before you even look at the pairs. The three you care about:

  • host — a local interface address. Always present. In Chrome these usually appear as an mDNS hostname ending in .local rather than a raw IP; that is a privacy feature, not a bug, and such candidates still connect fine on the same LAN.
  • srflx (server reflexive) — your public address as seen by a STUN server. This is what lets two peers behind different NATs find each other.
  • relay — an address allocated on a TURN server that forwards your media. This is the fallback when a direct path is impossible.

The diagnostic value is in what is missing:

  • No srflx candidates. Your STUN request never got a response. Either UDP to port 3478 is blocked outbound, or the STUN server hostname is wrong or unreachable. Without srflx, peers on different networks cannot connect without TURN.
  • No relay candidates. The TURN allocation failed. The usual culprits are wrong credentials, an expired time-limited credential, the wrong scheme (turn: vs turns:), or a firewall blocking the TURN port. A connection that needs relay and has none will simply fail.
  • Only .local host candidates in Chrome. Normal. Do not chase this. The mDNS obfuscation is expected and resolves transparently between peers.

Before assuming your application code is at fault, confirm the servers themselves gather the candidate types you expect. You can do that in isolation with our ICE server tester, which runs the same gathering process against your STUN/TURN URLs and shows exactly which candidate types come back.

The three connection states

WebRTC exposes several state machines and conflating them causes a lot of wasted time. The three you watch:

  • iceGatheringStatenew → gathering → complete. This only describes whether the local side has finished collecting candidates. It says nothing about whether a connection works.
  • iceConnectionState — tracks connectivity at the ICE layer: new → checking → connected → completed, with disconnected and failed as failure paths.
  • connectionState — the aggregate that also accounts for DTLS: new → connecting → connected → failed. This is the state most application logic should react to, because it only reads connected once media transport is genuinely usable.

A few sequences and what they mean:

  • checking → failed (never reaches connected): no candidate pair worked. Suspect a firewall, missing TURN, or candidates that were gathered but never delivered to the peer over signaling.
  • connected → disconnected → connected: a transient network blip. ICE lost consent freshness briefly and recovered. Usually benign; frequent recurrences point to an unstable link.
  • connected → disconnected → failed: the path dropped and ICE could not find a replacement. On mobile this often means a network switch with no relay candidate to fall back to. An ICE restart is the recovery path.

getStats(): reading the truth at runtime

getStats() is the programmatic version of everything webrtc-internals shows. It returns a map of report objects keyed by id. The report types you reach for most:

  • candidate-pair — one per probed pair. The nominated, in-use pair has state: "succeeded" and nominated: true. It carries currentRoundTripTime and byte counters for the active path.
  • outbound-rtp — what you are sending: packetsSent, bytesSent, frameWidth for video.
  • remote-inbound-rtp — what the far end reports receiving from you, via RTCP feedback. This is where you find the round-trip packetsLost and jitter as measured by the peer, which is far more honest about the link than your local send counters.
async function snapshot(pc) {
  const stats = await pc.getStats();
  let pair, outbound, remoteInbound;

  stats.forEach((report) => {
    if (report.type === "candidate-pair" && report.nominated && report.state === "succeeded") {
      pair = report;
    }
    if (report.type === "outbound-rtp" && report.kind === "video") {
      outbound = report;
    }
    if (report.type === "remote-inbound-rtp" && report.kind === "video") {
      remoteInbound = report;
    }
  });

  if (pair) {
    // Resolve the local/remote candidate ids to see host vs srflx vs relay
    const local = stats.get(pair.localCandidateId);
    const remote = stats.get(pair.remoteCandidateId);
    console.log("active path:", local?.candidateType, "->", remote?.candidateType);
    console.log("RTT (ms):", (pair.currentRoundTripTime ?? 0) * 1000);
  }
  if (outbound) console.log("packetsSent:", outbound.packetsSent);
  if (remoteInbound) console.log("packetsLost (as seen by peer):", remoteInbound.packetsLost);
}

setInterval(() => snapshot(peerConnection), 2000);

The candidate-type lookup at the end is the trick that turns raw stats into a diagnosis. If the active path reads relay -> relay, every byte is going through TURN, which is correct for hard NATs but also where your bandwidth costs live. If it reads host -> host across the public internet, something is wrong with how you collected candidates.

Command-line checks for the servers

When candidates are missing, the question becomes whether the server is reachable at all. These checks run from the same network the client sits on, which matters because a firewall difference is often the whole bug.

Probe the STUN/TURN UDP port. A response (rather than a hang) confirms UDP 3478 is open outbound:

# Send a byte and wait briefly; no immediate "refused" means the port is reachable
nc -u -w 2 turn.example.com 3478

# A more thorough STUN check with the coturn client utility:
turnutils_stunclient -p 3478 turn.example.com

Check the TLS listener that turns: uses on 5349:

openssl s_client -connect turn.example.com:5349 -servername turn.example.com

A valid certificate chain and a completed handshake confirm that turns: can work. A certificate that does not match the hostname is a common reason turns: silently fails to gather relay candidates while turn: works.

On the server, coturn logs are decisive. Run it with verbose and watch for allocation lines. A successful relay shows an allocation with a port in the relay range (commonly 49152–65535); a 401 means the credential was rejected, which on a time-limited setup usually means the timestamp expired.

# Watch coturn allocations and auth results live
tail -f /var/log/turnserver.log | grep -E "allocation|401|realm"

The gotchas that waste the most time

  • Expired time-limited TURN credentials. The REST-style credential scheme encodes an expiry timestamp in the username. If your client caches credentials and reuses them past expiry, TURN returns 401 and no relay candidates appear. Fetch fresh credentials per session.
  • Symmetric NAT on both sides, no TURN. Two symmetric NATs cannot be traversed with STUN alone; the predicted port never matches. The only fix is a relay. If your call works on some networks but not others, this is the leading suspect.
  • VPNs. A corporate or consumer VPN can route media through an unexpected interface, block UDP entirely, or add a NAT layer that defeats hole-punching. Test with the VPN off to isolate it.
  • ufrag mismatch from stale renegotiation. If signaling delivers an SDP from a previous negotiation, the ICE username fragment will not match and connectivity checks are rejected. This shows up as candidates that look correct but pairs that all fail. An ICE restart with a fresh ufrag clears it.

When in doubt, isolate the variable. Confirm the servers gather the right candidate types on their own before you blame application code, and confirm the application logic on a network you control before you blame the user's. For the error messages themselves, our guide to common WebRTC errors maps specific exceptions to causes, and the ICE framework explainer covers why candidate gathering behaves the way it does.

Check Your Own Servers

Before you blame your application code, prove your STUN and TURN servers work in isolation. Our tester runs the real ICE gathering process against your server URLs and shows exactly which candidate types come back and how long each takes.

Open the Free ICE Server Tester →