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, andsignalingStatetell 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(). TheRTCStatsReportexposes 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 everygetStats()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.
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.
- 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
signalingStatereturn tostable. - ICE reached a connected state. Once both sides have descriptions, ICE probes candidate pairs to find a path through the NAT. Confirm
iceConnectionStatemoves pastcheckingtoconnected. - DTLS completed. The transport encrypts before any media or data flows. Confirm the
transportstat reportsdtlsState: "connected". getStats()shows bits flowing on a healthy path. Aconnectedstate 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 stable → have-local-offer → stable on the offerer, and stable → have-remote-offer → stable 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: new → gathering → complete. 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/responsesReceivedgives 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 saysconnected.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 reachesconnectedafter ICE connects, the DTLS handshake failed, usually a certificate fingerprint mismatch in the SDP or a middlebox dropping DTLS.selectedCandidatePairId: the id of the activecandidate-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, orother. The single most useful field for "why is my video soft."cpumeans the encoder cannot keep up;bandwidthmeans 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 againstavailableOutgoingBitratefrom 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 beopenbefore 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 againstoutbound-rtpdimensions 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.
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 reachesconnectedis 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 … packetsLostandjitter. 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 whethercpuorbandwidthdominated 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.
- Read config. In
chrome://webrtc-internals, confirm the connection header lists the STUN server you expect. A missing or mistypediceServersentry fails everything downstream. - Confirm signaling delivery. Read the event log. Both peers must show
setLocalDescriptionandsetRemoteDescription. A missingsetRemoteDescriptionmeans the offer or answer never arrived. Fix the signaling transport and stop here. ConfirmsignalingStatereturned tostableon both sides. - Confirm gathering. Check the
icecandidateevents and the ICE grid. You needsrflxcandidates, not justhost. None means STUN is unreachable. Checkicecandidateerrorand the STUN URL, and stop here. - Confirm connectivity. Watch
iceConnectionState. It must reachconnected. Stuck incheckingor landing infailedis a NAT/firewall problem; inspect the candidate-pair grid for anysucceededpair. With STUN-only there is no relay fallback, so a fully symmetric-NAT path cannot connect. See ICE & NAT. - Confirm encryption. Read the
transportstat'sdtlsState. ICEconnectedbut DTLS notconnectedis a handshake failure, not a network failure. Look for a fingerprint mismatch in the exchanged SDP. - Confirm flow. With everything connected, sample
getStats(). The active candidate-pairbytesReceivedandbytesSentmust increase between samples. Flat counters mean a stalled or one-way path; check the sending side'soutbound-rtpordata-channel: the problem is usually that nothing is being sent, not that nothing arrives. - Judge quality. Once bytes flow, read the deltas:
packetsLostrate,jitter,currentRoundTripTime, andqualityLimitationReason. Cross-check outbound quality againstremote-inbound-rtpfor 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.
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.