Debugging: Reading a Live Connection

WebRTC fails quietly. A normal web app throws: a fetch rejects, a status code is non-200, a stack trace points at a line. A peer connection rarely does any of that. The API calls all resolve, the promises all settle, and then nothing arrives. No frame, no message, no error. The connection is not broken in a way the runtime can report, because from the runtime's view nothing went wrong: the negotiation succeeded and the candidates were exchanged. The data path just never lit up.

That silence is the core difficulty. A request-response API tells you when it breaks. A peer connection is a long-lived, two-sided, stateful pipeline running over UDP across two NATs you do not control. Any of a dozen stages can fail without raising an exception. Debugging WebRTC is therefore the work of making an invisible pipeline observable.

You have three tools for that, layered from coarse to fine:

  • State. connectionState, iceConnectionState, and signalingState tell you which stage the handshake reached and where it stalled. This is the first signal, and usually the only one you need to localize a failure.
  • getStats(). The RTCStatsReport exposes the internal media engine: bytes, packets, loss, jitter, round-trip time, codecs, the selected candidate pair. This confirms whether bits actually move once a connection claims to be up, and how well.
  • chrome://webrtc-internals. A zero-code live grapher built into the browser. It records every getStats() field over time and plots it, with no instrumentation in your app.

This guide covers all three: the layered method that decides which to reach for, a complete tour of the RTCStatsReport by stat type, a reusable polling helper that turns cumulative counters into live rates, a panel-by-panel walk through chrome://webrtc-internals, and a triage runbook that maps symptoms to causes across signaling, ICE, DTLS, and the media or data path.

coarse fine signaling ICE DTLS media / data State events which stage did the handshake reach? getStats() do bits actually move, and how well? chrome://webrtc-internals every field plotted live over time

The layered method

Debug in the same order the connection is built. Each layer depends on the one below it, so a failure at the bottom looks like a failure everywhere above it. Confirm each layer before moving up.

  1. Signaling delivered the offer and the answer. WebRTC needs an out-of-band channel to swap SDP. If your signaling drops a message, the connection never starts. Confirm both descriptions arrived and were applied. Watch signalingState return to stable.
  2. ICE reached a connected state. Once both sides have descriptions, ICE probes candidate pairs to find a path through the NAT. Confirm iceConnectionState moves past checking to connected.
  3. DTLS completed. The transport encrypts before any media or data flows. Confirm the transport stat reports dtlsState: "connected".
  4. getStats() shows bits flowing on a healthy path. A connected state means a path was found, not that it is good. Read the selected candidate pair's byte counters and RTT to confirm media or data actually moves, then read loss and jitter to judge quality.

Skipping a layer wastes time. A frozen video is not an encoder bug if ICE never left checking: the path was never built. A silent data channel is not a serialization bug if DTLS never reached connected. Read state first, in order.

The mistake to avoid is reaching for getStats() while the connection is still in checking. There are no meaningful media stats until a candidate pair is nominated and DTLS completes. Stats answer "how is the path performing"; state answers "is there a path at all." Ask the second question first.

State as the first signal

Attach handlers before you create any offer. State transitions fire early, and a handler added after setLocalDescription() may miss the first changes.

const pc = new RTCPeerConnection({
  iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});

pc.addEventListener("signalingstatechange", () => {
  console.log("signaling:", pc.signalingState);
});

pc.addEventListener("icegatheringstatechange", () => {
  console.log("ice gathering:", pc.iceGatheringState);
});

pc.addEventListener("iceconnectionstatechange", () => {
  console.log("ice:", pc.iceConnectionState);
});

pc.addEventListener("connectionstatechange", () => {
  console.log("connection:", pc.connectionState);
  if (pc.connectionState === "failed") {
    // ICE found no working path. See /connecting/ice-nat.
  }
});

pc.addEventListener("icecandidate", (e) => {
  // null candidate = gathering finished.
  console.log(e.candidate ? e.candidate.candidate : "gathering complete");
});

pc.addEventListener("icecandidateerror", (e) => {
  // STUN/TURN server rejected or was unreachable.
  console.warn("ice candidate error:", e.errorCode, e.errorText, e.url);
});

Read the states together. They answer different questions.

signalingState tracks the offer/answer exchange. It moves stablehave-local-offerstable on the offerer, and stablehave-remote-offerstable on the answerer. Stuck in have-local-offer means you sent an offer and never applied an answer: the answer was lost in transit or never generated. Stuck in have-remote-offer means you received an offer and never produced an answer: your createAnswer() / setLocalDescription() path is broken. See the connection state machine for the full transition table and signaling for the exchange itself.

iceGatheringState tracks candidate discovery: newgatheringcomplete. It reaching complete while the icecandidate event produced only host candidates (no srflx) means STUN never answered.

iceConnectionState tracks path connectivity. checking means candidates exist on both sides but no pair has succeeded yet. connected means a pair works. failed means every pair was tried and none worked. disconnected is often transient (a few lost keepalives) and may recover on its own.

connectionState is the aggregate of ICE plus DTLS. It is the single field to watch for "is this usable." connected is good; failed is terminal; disconnected may recover. Prefer it over iceConnectionState for top-level health because it also accounts for the DTLS handshake.

The icecandidate and icecandidateerror events are the early tells for gathering itself. With STUN-only and no TURN fallback, an unreachable STUN server leaves you with host candidates only, which fail across most NATs. If icecandidateerror fires with the STUN URL, the server is unreachable or blocked. See ICE and NAT traversal.

getStats() and the RTCStatsReport

getStats() returns a promise for an RTCStatsReport, a read-only map of stat objects keyed by id. Each object has a type and a timestamp. The objects reference one another by id: a candidate-pair names its localCandidateId and remoteCandidateId; an inbound-rtp names its transportId and codecId; an outbound-rtp names its mediaSourceId and remoteId. To read a connection you iterate the report, pick out the types you care about, and follow the id references between them.

const report = await pc.getStats();
report.forEach((stat) => {
  // stat.id, stat.type, stat.timestamp on every object
});

The rest of this section is a tour by type. For each, the fields that matter and how to read them.

candidate-pair

The chosen network path. ICE forms a pair for every combination of a local and remote candidate, runs connectivity checks, and nominates one. The active pair is the one with state: "succeeded" and nominated: true. Read it via pc.getStats() and filter, or jump straight to it through the transport stat's selectedCandidatePairId.

Fields that matter:

  • currentRoundTripTime: seconds for the most recent STUN request/response on this pair. This is your live latency. Multiply by 1000 for milliseconds. totalRoundTripTime / responsesReceived gives the lifetime average.
  • availableOutgoingBitrate: the congestion controller's current estimate of send headroom in bits per second. This is the number to cap your encoder against. It tracks the Google Congestion Control estimate described in the throughput inspector.
  • availableIncomingBitrate: the same estimate for the receive direction, when reported.
  • bytesSent / bytesReceived: cumulative octets on this path. The single most useful counters for "is anything moving." Flat across samples means a stalled path even when state says connected.
  • requestsSent / responsesReceived: STUN keepalive traffic. Requests climbing with no responses means the path is dropping.
  • state: succeeded, in-progress, failed, frozen. Useful when no pair is nominated yet and you want to see how checks are progressing.

Resolve the candidate ids to see which path won: host, server-reflexive (srflx), or relay. With STUN-only there is no relay; a nominated pair is always host-to-host or srflx-to-srflx.

transport

The DTLS/ICE transport underneath every stream. One per bundled connection in the common case.

  • dtlsState: new, connecting, connected, closed, failed. If this never reaches connected after ICE connects, the DTLS handshake failed, usually a certificate fingerprint mismatch in the SDP or a middlebox dropping DTLS.
  • selectedCandidatePairId: the id of the active candidate-pair. Use it to jump straight to the live path without scanning.
  • dtlsCipher / srtpCipher: the negotiated ciphers, for confirming encryption parameters.
  • bytesSent / bytesReceived: totals across all streams on this transport, including STUN and DTLS overhead, so slightly higher than the sum of the RTP streams.
  • iceState: mirrors the transport-level ICE state.

inbound-rtp

A media stream you receive. One per inbound track; filter on kind ("audio" or "video"). These are the fields the user actually experiences.

  • packetsReceived / packetsLost: cumulative. Compute the loss rate from the delta between samples, never from lifetime totals.
  • jitter: variance in packet arrival timing, in seconds. High jitter forces the receiver's buffer to grow, which adds latency. Multiply by 1000 for milliseconds.
  • bytesReceived: cumulative payload octets; delta gives receive bitrate.
  • framesPerSecond, framesDecoded, framesDropped, frameWidth, frameHeight (video): decoded output. Dropped frames or shrinking resolution signal the receiver or network struggling.
  • jitterBufferDelay / jitterBufferEmittedCount: divide one by the other for average time a sample spent buffered. Rising buffer delay is the mechanism behind audio that drifts late.
  • nackCount, pliCount, firCount (video): feedback this receiver sent asking for retransmits or keyframes. Climbing counts indicate a lossy link.
  • concealmentEvents, insertedSamplesForDeceleration (audio): the engine concealing gaps. The audible signature of loss.

outbound-rtp

A media stream you send. One per outbound track.

  • packetsSent / bytesSent: cumulative; delta gives your send bitrate.
  • framesEncoded, framesPerSecond, frameWidth, frameHeight (video): what the encoder produced. Compare against the source resolution to see downscaling.
  • qualityLimitationReason: none, cpu, bandwidth, or other. The single most useful field for "why is my video soft." cpu means the encoder cannot keep up; bandwidth means congestion control is throttling you.
  • qualityLimitationDurations: seconds spent in each limitation reason, so you can see whether CPU or bandwidth dominates over time.
  • retransmittedPacketsSent: packets re-sent in response to NACKs; rises with downstream loss.
  • targetBitrate: the bitrate the encoder is currently aiming for. Compare against availableOutgoingBitrate from the candidate-pair.

remote-inbound-rtp

What the far end reports about the stream you sent it, fed back over RTCP. This is the only way to learn the loss and timing the receiver actually sees. Your own outbound-rtp cannot tell you that. Match it to its outbound-rtp via the localId field.

  • packetsLost: loss the remote peer observed on your stream. The authoritative loss figure for your outbound media.
  • jitter: jitter the remote peer measured.
  • roundTripTime: RTT computed from RTCP sender/receiver reports, a second latency source independent of the candidate-pair STUN timing.
  • fractionLost: loss over the last reporting interval, already a fraction, so no delta needed.

data-channel

For RTCDataChannel traffic. When a connection carries no media there are no *-rtp stats at all, and this is where the signal lives.

  • state: connecting, open, closing, closed. Must be open before anything sends.
  • messagesSent / messagesReceived: message counts.
  • bytesSent / bytesReceived: payload octets; delta gives data-channel throughput.
  • label / protocol: identify the channel when several share a connection.

Note that data-channel byte counters live above SCTP, so they reflect application payload, while the transport and candidate-pair byte counters include SCTP, DTLS, and STUN overhead. Comparing the two tells you the framing cost. For ramp-up behavior and the SCTP congestion window, see the throughput inspector.

codec

The negotiated codec for a stream. Referenced by codecId from the RTP stats.

  • mimeType: e.g. video/VP8, video/H264, audio/opus.
  • payloadType: the RTP payload type number.
  • clockRate, channels, sdpFmtpLine: parameters from the SDP, useful to confirm both peers agreed on the codec you expected. A surprise codec (a software fallback instead of hardware H264) explains unexpected CPU limitation.

media-source

The raw input before encoding, referenced by mediaSourceId from outbound-rtp.

  • width, height, framesPerSecond (video): the source resolution and rate from the camera or canvas. Compare against outbound-rtp dimensions to separate capture problems from encode downscaling.
  • audioLevel, totalAudioEnergy (audio): input signal level. A flat zero means the microphone is muted or capturing silence, which explains "the other side hears nothing" before you suspect the network.

A complete polling helper

A single getStats() call is a snapshot. The counters are cumulative, so one reading of bytesReceived says nothing about whether data flows now. The signal lives in the difference between two samples, divided by the time between them. Poll on an interval, keep the previous report, and compute deltas. This turns a frozen call into a measurable trend: a flat byte counter across samples is the difference between "slow" and "dead."

The helper below polls once per second, finds the active candidate pair and the inbound media, and reports bitrate, packet-loss rate, RTT, and jitter as live values. It uses each report's own timestamp rather than assuming the interval fired exactly on time, which keeps the rates honest under load.

// Live connection monitor. Computes per-interval rates from cumulative
// getStats counters. Emits a snapshot object you can log, graph, or push
// onto the salon event bus (src/salon/bus.js) for the demo log panel.
export function monitorConnection(pc, onSample, intervalMs = 1000) {
  let prev = null;

  const tick = async () => {
    const report = await pc.getStats();

    let pair = null;
    let inbound = null;
    let outbound = null;
    let remoteInbound = null;
    let channel = null;
    let transport = null;

    report.forEach((stat) => {
      switch (stat.type) {
        case "candidate-pair":
          if (stat.nominated && stat.state === "succeeded") pair = stat;
          break;
        case "inbound-rtp":
          if (stat.kind === "video" || !inbound) inbound = stat;
          break;
        case "outbound-rtp":
          if (stat.kind === "video" || !outbound) outbound = stat;
          break;
        case "remote-inbound-rtp":
          remoteInbound = stat;
          break;
        case "data-channel":
          if (stat.state === "open") channel = stat;
          break;
        case "transport":
          transport = stat;
          break;
      }
    });

    const snap = {
      ts: report[Symbol.iterator] ? Date.now() : Date.now(),
      connectionState: pc.connectionState,
      iceState: pc.iceConnectionState,
      dtlsState: transport?.dtlsState ?? null,
      rttMs: pair ? Math.round(pair.currentRoundTripTime * 1000) : null,
      availableOutKbps: pair?.availableOutgoingBitrate
        ? Math.round(pair.availableOutgoingBitrate / 1000)
        : null,
    };

    if (prev) {
      // Seconds between this report and the last, from the stats clock.
      const dt = (() => {
        const a = pair ?? inbound ?? channel ?? transport;
        const b = a ? prev.get(a.id) : null;
        return a && b ? (a.timestamp - b.timestamp) / 1000 : intervalMs / 1000;
      })();

      // Path throughput from the candidate pair.
      if (pair) {
        const p = prev.get(pair.id);
        if (p) {
          const dRecv = pair.bytesReceived - p.bytesReceived;
          const dSent = pair.bytesSent - p.bytesSent;
          snap.recvKbps = Math.round((dRecv * 8) / 1000 / dt);
          snap.sendKbps = Math.round((dSent * 8) / 1000 / dt);
          snap.stalled = dRecv === 0 && dSent === 0;
        }
      }

      // Inbound media loss + jitter, from per-interval deltas.
      if (inbound) {
        const p = prev.get(inbound.id);
        if (p) {
          const dPackets = inbound.packetsReceived - p.packetsReceived;
          const dLost = inbound.packetsLost - p.packetsLost;
          const total = dPackets + dLost;
          snap.lossPct = total > 0 ? +((dLost / total) * 100).toFixed(1) : 0;
          snap.jitterMs = Math.round(inbound.jitter * 1000);
          snap.fps = inbound.framesPerSecond ?? null;
        }
      }

      // Encoder pressure, if sending media.
      if (outbound) {
        snap.qualityLimitation = outbound.qualityLimitationReason;
        snap.targetKbps = outbound.targetBitrate
          ? Math.round(outbound.targetBitrate / 1000)
          : null;
      }

      // Loss the far end actually saw on your outbound stream.
      if (remoteInbound) {
        snap.remoteFractionLost = remoteInbound.fractionLost ?? null;
        snap.remoteRttMs = remoteInbound.roundTripTime
          ? Math.round(remoteInbound.roundTripTime * 1000)
          : null;
      }

      // Data-channel throughput when there is no media.
      if (channel) {
        const p = prev.get(channel.id);
        if (p) {
          const dBytes = channel.bytesReceived - p.bytesReceived;
          snap.dataRecvKbps = Math.round((dBytes * 8) / 1000 / dt);
        }
      }
    }

    prev = report;
    onSample(snap);
  };

  const timer = setInterval(tick, intervalMs);
  return () => clearInterval(timer);
}

Wire it to whatever consumes samples. In this project the salon event bus (src/salon/bus.js) is the natural sink: modules emit(kind, payload) on every meaningful WebRTC event, and the demo log panel subscribes. Pushing each snapshot onto the bus puts live RTT, loss, and bitrate in the same timeline as state transitions and handshake steps, so a quality dip lines up visually with the disconnected event that caused it.

import { emit } from "../salon/bus.js";

const stop = monitorConnection(pc, (snap) => {
  emit("stats", snap);
  if (snap.stalled) emit("warn", "connected but no bytes: path stalled");
});
// later: stop();

Two rules keep the numbers truthful. Compute loss from the interval delta, not lifetime totals: a call that lost 3% in its first minute and is now clean reads as 3% if you divide cumulative counters, but the delta reads 0%, which is what you want to see. And derive the interval length from the stats timestamp, not the timer, because setInterval drifts under main-thread load and a wrong dt skews every rate.

sample at t bytesReceived = 120k sample at t+1 bytesReceived = 168k elapsed = timestamp delta delta = 48k bytes delta ÷ elapsed = live rate

chrome://webrtc-internals, panel by panel

Open chrome://webrtc-internals in a second tab before you start a connection: it only records connections created after the page is open. It captures every active RTCPeerConnection in the browser and exposes everything getStats() returns, plus the full API call log, with no code in your app. Firefox has the equivalent at about:webrtc.

Each open connection expands into several panels.

The header. Shows the RTCPeerConnection constructor arguments, your iceServers list among them. Confirm the STUN URL you expect is actually there. A typo in the config surfaces here before anything else.

The event log (API trace). A chronological list of every API call and callback: createOffer, setLocalDescription, setRemoteDescription, createAnswer, each icecandidate, and every state transition with its new value. Read it top to bottom to verify the handshake ran in order. The most common finding is a missing line: no setRemoteDescription means signaling never delivered the far side's SDP, which pins the failure to the signaling layer instantly. A setRemoteDescription immediately followed by an error means the SDP arrived but was malformed or out of order.

The ICE candidate grid. A table of every candidate gathered and received, with type (host, srflx, relay), protocol, address, and port. With STUN-only you expect host and srflx rows and no relay. No srflx rows means STUN never answered: the gathering failure described above. Below the candidates, the candidate-pair section shows each pair's state and which one is nominated. A grid full of pairs all stuck in in-progress or failed with none succeeded is the visual signature of a checking stall: candidates exist on both sides, but no path works through the NAT. See ICE & NAT.

The stats graphs. Every numeric getStats() field, plotted live. The ones to read first:

  • candidate-pair … bytesReceived / bytesSent. A rising line means the path carries traffic. A line that goes flat right after the connection reaches connected is the unmistakable shape of a stalled or one-way path (the single most useful graph in the tool).
  • candidate-pair … currentRoundTripTime. Live latency. A baseline that climbs signals a filling router queue before loss even starts.
  • [bweForVideo] / availableOutgoingBitrate. Bandwidth estimation over time. A sawtooth that keeps collapsing means congestion control repeatedly hitting loss.
  • inbound-rtp … packetsLost and jitter. Plotted as cumulative and rate; the rate graph shows exactly when quality dropped.
  • outbound-rtp … qualityLimitationReason. Plotted as a step graph: see at a glance whether cpu or bandwidth dominated and for how long.

The dump button. "Create a WebRTC-Internals dump" writes a JSON file containing the entire event log and stats history. Attach it to a bug report; another engineer can replay the whole connection without reproducing it.

The division of labor: use chrome://webrtc-internals to discover what failed because the graphs make a flat counter obvious, then use your own getStats() instrumentation to react to it in production where you have no browser tab open.

Common failure signatures

Each layer fails with a recognizable shape. Match the symptom to the layer before you touch code.

Symptom Likely layer Cause Where to look
signalingState never leaves stable, no offer sent App logic createOffer() / setLocalDescription() never called Negotiation code; missing negotiationneeded handler
signalingState stuck in have-local-offer Signaling Answer never delivered or never applied Signaling: confirm the transport delivered the answer SDP
signalingState stuck in have-remote-offer Signaling / app Offer received, answer never produced createAnswer() / setLocalDescription(answer) path
setRemoteDescription throws InvalidStateError App logic Offer/answer applied out of order, or glare State machine: perfect-negotiation rollback
iceGatheringState complete, only host candidates ICE / STUN STUN unreachable or UDP blocked outbound ICE & NAT: verify STUN URL/port; check icecandidateerror
icecandidateerror fires with the STUN URL ICE / STUN STUN server down, wrong port, or firewalled Config iceServers; try an alternate public STUN
iceConnectionState stuck in checking ICE / NAT Candidates exchanged but no pair succeeds ICE & NAT: symmetric NAT or firewall (no TURN fallback here)
iceConnectionState reaches failed ICE / NAT Every candidate pair failed connectivity checks ICE & NAT: network path blocks UDP entirely
connected, then disconnected intermittently Network Transient loss, roaming, sleep, lost keepalives Often self-recovers; watch for return to connected
ICE connected, dtlsState never connected DTLS / transport DTLS handshake failed transport stat: certificate fingerprint mismatch in SDP, middlebox dropping DTLS
connectionState connected, byte counters flat Media / data path Path nominated but nothing sent, or one-way path getStats() deltas; check the sending peer's outbound-rtp / data-channel
Data channel state never open Data path SCTP never established over DTLS data-channel stat; confirm dtlsState: connected first
Inbound video present, framesDecoded flat Media Decoder stalled or no keyframe received inbound-rtp pliCount / firCount; request a keyframe
High packetsLost in inbound-rtp Network Congestion or a lossy link Reduce send bitrate; read availableOutgoingBitrate
High jitter, rising jitterBufferDelay Network Variable arrival timing Buffer absorbs some; lower framerate if severe
outbound-rtp qualityLimitationReason: cpu Local CPU Encoder cannot keep up Lower resolution/framerate; check for software codec in codec stat
outbound-rtp qualityLimitationReason: bandwidth Network Congestion control throttling the encoder Cap targetBitrate; compare against availableOutgoingBitrate
Remote hears silence, your media-source audioLevel is 0 Capture Microphone muted or capturing silence getUserMedia track state; not a network problem

The triage runbook

A repeatable pass, in order. Stop at the first layer that fails: everything above it is a symptom of it.

  1. Read config. In chrome://webrtc-internals, confirm the connection header lists the STUN server you expect. A missing or mistyped iceServers entry fails everything downstream.
  2. Confirm signaling delivery. Read the event log. Both peers must show setLocalDescription and setRemoteDescription. A missing setRemoteDescription means the offer or answer never arrived. Fix the signaling transport and stop here. Confirm signalingState returned to stable on both sides.
  3. Confirm gathering. Check the icecandidate events and the ICE grid. You need srflx candidates, not just host. None means STUN is unreachable. Check icecandidateerror and the STUN URL, and stop here.
  4. Confirm connectivity. Watch iceConnectionState. It must reach connected. Stuck in checking or landing in failed is a NAT/firewall problem; inspect the candidate-pair grid for any succeeded pair. With STUN-only there is no relay fallback, so a fully symmetric-NAT path cannot connect. See ICE & NAT.
  5. Confirm encryption. Read the transport stat's dtlsState. ICE connected but DTLS not connected is a handshake failure, not a network failure. Look for a fingerprint mismatch in the exchanged SDP.
  6. Confirm flow. With everything connected, sample getStats(). The active candidate-pair bytesReceived and bytesSent must increase between samples. Flat counters mean a stalled or one-way path; check the sending side's outbound-rtp or data-channel: the problem is usually that nothing is being sent, not that nothing arrives.
  7. Judge quality. Once bytes flow, read the deltas: packetsLost rate, jitter, currentRoundTripTime, and qualityLimitationReason. Cross-check outbound quality against remote-inbound-rtp for the loss the receiver actually sees. React with bitrate or framerate adaptation.

Run this with the polling helper feeding the event bus so each step's evidence lands in one timeline. For the full state model see the connection state machine, for candidate failures see ICE & NAT, for the SDP exchange see signaling, and the RTCPeerConnection reference documents every event and method named above.

C++ MEDIA ENGINE JAVASCRIPT APPLICATION Network LayerUDP sockets Jitter Bufferaudio / video Hardware Encoder Internal StatsAggregator pc.getStats() Dashboard /Telemetry UI Adaptation Logice.g. lower resolution packet loss / RTT jitter / delay bitrate / FPS RTCStatsReport setParameters(): adapt encoder
The Adaptation Loop

Monitoring is only half the work. Use this data to adapt. If you detect high packet loss, don't just log it: programmatically lower the maximum bitrate of your video sender to keep the call alive.

Test your connection's limits with the Throughput Explorer or learn how to simulate network failure with the Network Resilience module.