Why Your ICE Connection Fails (And How to Debug It)
A practical guide to diagnosing and fixing the most common WebRTC ICE connection failures
You've set up your signaling server, exchanged SDP offers and answers, and... nothing. The connection just sits at "checking" forever, then fails. Welcome to ICE debugging.
ICE (Interactive Connectivity Establishment) is the NAT traversal protocol that makes WebRTC actually work across the internet. When it fails, it fails silently. Here's how to figure out what's going wrong.
The ICE Connection States
First, understand what the states mean:
- new - Just created, no checks yet
- checking - Testing candidate pairs
- connected - At least one working pair found
- completed - All checks done, best pair selected
- failed - No working pairs found
- disconnected - Connectivity lost temporarily
- closed - Connection shut down
If you're stuck at "checking" for more than 5-10 seconds, something is wrong. If you jump straight to "failed", something is very wrong.
The Three Most Common Failures
1. Missing TURN Server
This is the #1 cause of production failures. STUN only works when at least one peer has a public IP or is behind a permissive NAT. In the real world, that's maybe 70% of connections. The other 30% need TURN.
// Wrong: STUN only
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
// Right: STUN + TURN
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.yourserver.com:443?transport=tcp',
username: 'user',
credential: 'pass'
}
]
}; Pro tip: Always include a TCP TURN server on port 443. Corporate firewalls that block UDP often allow TCP on 443 because it looks like HTTPS.
2. Candidates Not Trickling
If you're using trickle ICE (you should be), candidates need to flow in both directions via your signaling server. A common bug: candidates arrive before the remote description is set.
// Wrong: Adding candidates immediately
socket.on('ice-candidate', (candidate) => {
pc.addIceCandidate(candidate); // Might fail!
});
// Right: Queue until ready
const candidateQueue = [];
socket.on('ice-candidate', (candidate) => {
if (pc.remoteDescription) {
pc.addIceCandidate(candidate);
} else {
candidateQueue.push(candidate);
}
});
// After setRemoteDescription
await pc.setRemoteDescription(answer);
for (const candidate of candidateQueue) {
await pc.addIceCandidate(candidate);
}
candidateQueue.length = 0; 3. Symmetric NAT
Symmetric NAT assigns a different external port for every destination. STUN gives you one port, but the peer needs a different one. Only TURN can fix this.
How to detect: If you're getting "srflx" (server reflexive) candidates from STUN but still failing, symmetric NAT is likely the culprit. The fix is TURN.
Debugging Tools
chrome://webrtc-internals
This is your best friend. Open it in Chrome while your app is running. You'll see:
- Every ICE candidate gathered and received
- Which pairs were tested and their results
- The selected candidate pair
- Detailed timing information
Look at the ICE candidate pair stats. If you only see "host" and "srflx" candidates failing, you need TURN. If you're not even getting candidates, check your STUN/TURN server configuration.
Log the Connection State
pc.oniceconnectionstatechange = () => {
console.log('ICE state:', pc.iceConnectionState);
};
pc.onicegatheringstatechange = () => {
console.log('ICE gathering:', pc.iceGatheringState);
};
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log('Local candidate:', event.candidate.type,
event.candidate.protocol, event.candidate.address);
}
}; Test Your TURN Server
Use Trickle ICE from the WebRTC samples. Add your TURN credentials and hit "Gather candidates". You should see "relay" candidates if TURN is working.
The ICE Restart
If connectivity is lost temporarily (network switch, brief disconnection), don't tear down and rebuild. Use ICE restart:
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
pc.restartIce();
// Re-negotiate
pc.createOffer({ iceRestart: true })
.then(offer => pc.setLocalDescription(offer))
.then(() => sendOffer(pc.localDescription));
}
}; This keeps the existing streams and data channels alive while establishing new connectivity.
Production Checklist
- Always include a TURN server
- Include TCP TURN on port 443
- Queue candidates until remote description is set
- Log ICE states for debugging
- Implement ICE restart for recovery
- Test on restrictive networks (mobile data, corporate WiFi)
- Monitor TURN server capacity and latency
Further Reading
- RFC 8445 - ICE - The full spec
- WebRTC for the Curious - Excellent free book
- BlogGeek.me WebRTC Glossary - Terminology reference
ICE debugging is frustrating because failures are silent and network conditions vary wildly. But with proper logging and a reliable TURN server, you can get to 99%+ connection success rates. The remaining 1%? That's usually a firewall that blocks everything, and there's not much you can do about that.