WebRTC ICE Connection Failed: How to Diagnose and Fix It
WebRTC ICE connection failed means every candidate pair was checked and none worked. How to read webrtc-internals, then fix the causes in likelihood order.
WebRTC ICE connection failed means every candidate pair was checked and none worked. How to read webrtc-internals, then fix the causes in likelihood order.
ICE connection failed means the browser's ICE agent tried every pairing of local and remote network addresses it knew about, and none of them carried traffic. No line of your code threw it — the connection reached the end of a negotiation and came up empty, which is why the error text alone tells you almost nothing about the cause. What tells you the cause is chrome://webrtc-internals: it shows which candidates each side gathered and which pairs were probed, and that single view separates six unrelated bugs from each other.
ICE (Interactive Connectivity Establishment) is the negotiation each peer runs to find a usable network path to the other. Each side gathers candidates — host (its own LAN address), srflx (its public address as reported by a STUN server), and relay (an address on a TURN server) — ships them to the peer over your signaling channel, then both sides probe every local/remote pair with STUN connectivity checks.
pc.iceConnectionState becomes failed when, in MDN's wording, the agent "has checked all candidates pairs against one another and has failed to find compatible matches for all components of the connection." Firefox prints the same condition as ICE failed, add a STUN server and see about:webrtc for more details.
Two things follow from that definition. ICE failure is a network outcome rather than a code exception, so stepping through your JavaScript will not find it. And it is reported at the end of the process, so by the time failed fires, the evidence that explains it — which candidate types existed, which pairs were tried — has already scrolled past unless you were capturing it.
Open the dashboard before you reproduce, not after.
chrome://webrtc-internals, opened in a separate tab before the call starts. Each RTCPeerConnection gets its own section with an ICE candidate grid and a full event log.about:webrtc.Then add four listeners. They take a minute to write and they turn "it failed" into a specific line item:
pc.addEventListener('icegatheringstatechange', () =>
console.log('gathering:', pc.iceGatheringState)); // new → gathering → complete
pc.addEventListener('iceconnectionstatechange', () =>
console.log('ice:', pc.iceConnectionState)); // new → checking → connected / failed
pc.addEventListener('icecandidate', ({ candidate }) =>
console.log('local:', candidate ? candidate.type : 'end-of-candidates',
candidate?.candidate));
pc.addEventListener('icecandidateerror', (e) =>
console.log('candidate error', e.errorCode, e.url, e.errorText));
iceGatheringState and iceConnectionState are separate machines and they fail in different places. Gathering answers "did this browser find any addresses to offer?" Connection answers "did any pair of addresses work?" Read them together and the failure lands in one of five buckets:
| Where it stops | What that tells you | Section |
|---|---|---|
iceGatheringState never leaves new, no icecandidate events | ICE never started — the SDP has no media in it | Cause 4 |
Only host candidates appear | No STUN server reachable; you are limited to the local network | Cause 1 |
host + srflx, never relay | TURN is missing, unreachable, or rejecting your credentials | Cause 1 |
All three types gathered, state sits in checking then flips to failed | Candidates existed on both sides but no pair validated | Causes 2 and 3 |
Reaches connected, then failed minutes later | The working path was lost mid-call | ICE restart, below |
Capture the remote side as well. A common failure is one peer sending candidates the other never applied, and a single browser's log cannot show you that — you need both grids side by side to see that A gathered a relay candidate B never received.
This is the first thing to rule out because it explains the majority of reports where the code works on one office network and fails between two homes. STUN alone lets two peers discover their public addresses and connect directly, and that works across most consumer NATs. It stops working behind a symmetric NAT — which allocates a different external port per destination, so the address STUN reported is useless to the peer — and behind corporate firewalls that drop inbound UDP outright. A TURN server relays the media instead of merely observing addresses, which is why it is the fallback that always works when it is configured correctly.
The webrtc-internals grid gives you the verdict in one glance: if no candidate with typ relay ever appears in your local candidate list, your TURN configuration is not working, whatever your config object says.
Four things break it, in the order we hit them:
urls uses the wrong scheme. TURN needs turn: or turns:, not stun:. A stun: URL with a username and credential attached is silently treated as STUN, and you get srflx candidates while wondering why relay never shows up.username and credential are missing or expired. TURN, unlike STUN, is authenticated. If you mint time-limited credentials (the standard coturn REST pattern), a token that expired while the page sat open produces exactly this symptom on the next call.icecandidateerror with errorCode 701 is telling you.Managed calling platforms — Twilio, Vonage, LiveKit — run their own TURN infrastructure, so this whole class of bug is mostly absent there; what fails instead is the signaling layer, which is a different symptom with a different fix (Twilio 53000 is that flavor).
WebRTC media wants UDP. Plenty of networks — hospitals, banks, schools, some hotel Wi-Fi, most corporate guest VLANs — permit outbound TCP on 80 and 443 and nothing else. On those networks a UDP-only TURN deployment is the same as no TURN at all: candidates gather, connectivity checks go out, nothing comes back, and after the checks time out you get failed.
The fix is to expose TURN over TCP and TLS, and to list those URLs explicitly. Browsers do not infer the transport:
iceServers: [
{ urls: 'stun:stun.example.com:3478' },
{ urls: 'turn:turn.example.com:3478?transport=udp', username: u, credential: c },
{ urls: 'turn:turn.example.com:3478?transport=tcp', username: u, credential: c },
{ urls: 'turns:turn.example.com:443?transport=tcp', username: u, credential: c },
]
The last line is the one that saves restrictive networks: turns: on 443 over TCP looks like ordinary HTTPS to a firewall doing port-based filtering.
One correction to a claim that circulates on Stack Overflow and Reddit — that TURN over TCP cannot help because "the relaying is still done over UDP." The leg from the TURN server onward does use UDP, but that leg originates on the public internet, well outside the firewall that was blocking you. The leg that matters is the one from the restricted client to the TURN server, and that leg is TCP. Deploying turns: on 443 genuinely fixes UDP-blocked clients, and you can prove it on your own network in two minutes with the Trickle ICE test below: block UDP, gather again, and watch the relay candidate reappear on the TCP URL.
Cost is the reason people avoid this, and it is a real trade-off: relayed calls consume bandwidth on your TURN server for the whole duration, where a direct path costs you nothing. That argues for putting relay last in preference order, not for leaving it out.
This one produces the most confusing signature: both sides gather a full set of candidates, everything looks correct in the logs, and the connection still sits in checking until it fails. The cause is ordering in your signaling code.
addIceCandidate() rejects with an InvalidStateError when remoteDescription is null. Trickle ICE means candidates start flowing the moment setLocalDescription() is called, so on a fast signaling channel the first remote candidates routinely land before the offer or answer they belong to. If your handler calls addIceCandidate() unconditionally and swallows the rejection in a .catch() that only logs, you drop those candidates permanently — and the ones you dropped are frequently the only ones that would have worked.
Queue them instead:
const pendingCandidates = [];
async function onRemoteCandidate(candidate) {
if (!pc.remoteDescription) {
pendingCandidates.push(candidate);
return;
}
await pc.addIceCandidate(candidate);
}
async function onRemoteDescription(desc) {
await pc.setRemoteDescription(desc);
while (pendingCandidates.length) {
await pc.addIceCandidate(pendingCandidates.shift());
}
}
Two related ordering bugs live next door. An OperationError from addIceCandidate() usually means the candidate's sdpMid or sdpMLineIndex does not match any media section in the remote description — normally the result of hand-assembling the candidate object instead of forwarding what onicecandidate handed you. And a 10,000-view thread on a connection stuck in connecting turned out to be the opposite mistake: the author buffered candidates and the offer, then sent everything at once, on the assumption that gathering finishes before the offer is created. It does not. Forward each candidate as it arrives and let the peer queue it.
If iceGatheringState stays new and you get zero icecandidate events, ICE never started. That is almost always because the offer contains no media, and both of the ways to cause that are worth knowing.
No tracks and no transceivers. Chrome changed OfferToReceiveAudio to default to false back in Chrome 39, and the accepted answer on the canonical thread (8,900 views) spells out the consequence: the SDP returned by createOffer() contains no media lines, so the gathering process never starts, and iceGatheringState and iceConnectionState both sit at new. For a receive-only peer, add a transceiver explicitly:
pc.addTransceiver('audio', { direction: 'recvonly' });
createOffer() called before addTrack(). The second most-viewed variant is a race in the getUserMedia flow. Firefox is honest about it and throws InternalError: Cannot create an offer with no local tracks, no offerToReceiveAudio/Video, and no DataChannel; Chrome quietly produces an unusable offer instead, which is why the bug so often reads as "works in Firefox, silent in Chrome" or the reverse. Await the microphone, add the tracks, then create the offer:
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((t) => pc.addTrack(t, stream));
await pc.setLocalDescription(await pc.createOffer());
If getUserMedia() itself is what failed, you have a permissions problem rather than an ICE problem — NotAllowedError and its neighbors cover that path.
Same code, one browser out of three. Two documented quirks account for most of these.
Safari will not emit host candidates until microphone permission is granted, even on a receive-only peer. A long-standing report describes exactly this: no ICE candidates at all until the author added a getUserMedia() call the connection did not otherwise need, after which candidates appeared. If your Safari users see zero candidates while Chrome users see a dozen, request permission before negotiating, and re-check after the user actually clicks Allow rather than when the prompt is shown.
Firefox refuses to gather loopback candidates, which breaks WebRTC between two tabs on localhost while the same code works in Chrome and Edge. That is Bugzilla 1659672, closed as INVALID, and it is the answer to a 14,000-view Stack Overflow question about ICE failing in Firefox while working in Edge. Setting media.peerconnection.ice.loopback to true in about:config unblocks local development; it is a developer-machine workaround, so do not build a product decision on top of it.
Before blaming a browser, check the boring explanation: the browsers were on different networks. A Safari session on an iPhone over cellular and a Chrome session on office Wi-Fi are two different NAT situations, and the cellular carrier's CGNAT is usually the one that needs relay.
Nothing exotic here, which is the point — most broken configurations are broken by an extra option rather than a missing one.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.example.com:3478' },
{
urls: [
'turn:turn.example.com:3478?transport=udp',
'turn:turn.example.com:3478?transport=tcp',
'turns:turn.example.com:443?transport=tcp',
],
username: turnUsername, // short-lived, minted server-side
credential: turnCredential,
},
],
iceCandidatePoolSize: 0,
});
Three notes on the fields. Keep the server list short — every extra STUN or TURN URL is another set of candidates to gather and another set of pairs to check, which slows the whole negotiation and gains nothing once you have one working relay. iceCandidatePoolSize defaults to 0 and prefetches candidates when raised; it can shave setup latency, and it also starts gathering earlier than you may expect, so leave it alone while you are debugging. And mint TURN credentials on your server with a short TTL — a static username and password shipped in your bundle is a relay anyone can bill you for.
Two tests, in this order, before you touch application code.
1. Google's Trickle ICE page. Open the official Trickle ICE test page, delete the default entries, add your TURN URL with real credentials, and click Gather candidates. A working TURN server produces at least one row of type relay. No relay row means the server, the credentials, or the port is the problem, and no amount of application debugging will change that. Run it again from the network where calls actually fail — a TURN server that passes from your desk and fails from a customer's office has told you which firewall to talk about.
2. Force relay in your own app. Set iceTransportPolicy: 'relay' in the config and place a call. That value tells the browser to consider only relayed candidates, discarding host and srflx entirely, so the call either connects through TURN or fails immediately:
const pc = new RTCPeerConnection({ iceServers, iceTransportPolicy: 'relay' });
If it connects with 'relay' but fails on 'all' for some users, TURN is healthy and the direct-path negotiation is what is broken. If it fails on 'relay' for everyone, go back to test 1. Take the flag out afterwards — leaving it on routes every call through your relay and multiplies your bandwidth bill.
Sometimes. pc.restartIce() requests a fresh round of candidate gathering with new credentials on both ends, and existing media keeps flowing while it runs. It is the right response to a connection that reached connected and later broke — a laptop moving from Wi-Fi to cellular, a NAT binding expiring, a route flapping:
pc.addEventListener('iceconnectionstatechange', () => {
if (pc.iceConnectionState === 'failed') {
pc.restartIce(); // fires negotiationneeded; renegotiate as usual
}
});
It will not save a connection that never worked. If the first negotiation failed because TURN was misconfigured or UDP was blocked, restarting ICE re-runs the same negotiation against the same broken network and fails the same way. Restarts also cost a full renegotiation round trip through your signaling channel, so cap them — two attempts, then surface a real error to the user — rather than looping.
One distinction that saves debugging time: if ICE connects, media flows one direction, and the state stays connected, ICE is not your problem. One-way audio is a track, SDP direction, or codec problem wearing a connectivity costume.
Do I need a TURN server, or is STUN enough? STUN alone connects most home-network users and costs nothing to run. It fails behind symmetric NAT, corporate firewalls, and some mobile carriers' CGNAT. Any product with users on networks you do not control needs TURN, because those users cannot connect at all without it and will report the app as simply broken.
Are free public STUN servers like Google's reliable for production? For STUN, they mostly work and are widely used in samples. They offer no availability guarantee, apply unpublished rate limits, and cannot help with symmetric NAT because STUN never relays. No free public TURN service is worth depending on — TURN carries your media and needs authentication, so run coturn or buy a hosted relay.
What's the difference between ICE failed and disconnected?
disconnected means connectivity checks stopped succeeding on at least one component; MDN describes it as a less stringent test that can trigger intermittently on unreliable networks and resolve on its own. failed is terminal for that negotiation. Wait out disconnected for a few seconds before reacting; act immediately on failed.
What does ICE error code 701 mean?
701 is not a real STUN error code. The browser reports it through the icecandidateerror event when no host candidate could reach a configured STUN or TURN server at all — a wrong hostname, a blocked port, a dead server. It fires once per server URL and only while iceGatheringState is gathering.
How long does ICE take before it reports failure?
Longer than users will wait, and no spec fixes the number — it depends on how many candidate pairs exist and how each browser paces its retransmissions. More servers means more pairs means a longer wait before failed. Show your own connecting-state UI on a timer you control instead of waiting for the state change.
Cause 1 has a footnote worth pulling out: on managed platforms this whole class of bug is mostly absent, because Twilio and Vonage run their own TURN infrastructure. You don't deploy coturn, expose turns: on 443, or mint rotating REST credentials — the relay is already there. Alloqui is a React dialer on your own Twilio or Plivo keys, so you get their relay footprint plus the client-side half this page covers: candidate queueing before the remote description arrives, transceivers added so gathering actually starts, ICE restart on failed rather than a page reload. You keep the account and the call records.