TL;DR
- ICE finds a working path between two NAT'd peers by gathering, exchanging, and testing candidate pairs.
- Host beats STUN beats TURN. ICE prefers direct connections and falls back to relaying only when it must.
- Trickle ICE speeds things up by testing candidates as they're found instead of waiting for the full list.
- Debug top-down: gathering -> exchange -> selected pair -> consent freshness, before ever touching DTLS or codecs.
WebRTC promises a direct connection between two devices, and the internet makes that hard. Both devices usually sit behind NATs, so neither one knows an address the other can actually reach.
ICE is the part of WebRTC that closes that gap. It's what lets a laptop on coffee-shop Wi-Fi stream video to a phone on a carrier network, with neither side knowing anything about the networks in between. It's also where a lot of "the call won't connect" bugs live, which makes it worth understanding even when your platform handles it for you.
What Is the ICE Protocol, and Why Does WebRTC Need It?
ICE stands for Interactive Connectivity Establishment, and the name describes the job accurately. It carries no media and does no signaling. It finds a network path that works between two peers, hands that path to the rest of the stack, and gets out of the way.
The reason it has to exist is NAT. A device behind a NAT only knows its private address, which means nothing outside its own network. The NAT will map that to a public address and port, but the mapping only appears once traffic flows, and it can change or expire. Neither peer can simply tell the other where to send packets, because neither one knows its own reachable address.
So ICE does four things:
- Discovers addresses. Each peer builds a list of every address it might be reachable at, with help from STUN and TURN servers.
- Exchanges them. The lists travel to the other peer over your signaling channel as part of the SDP exchange.
- Tests pairs. Both sides send small probes across combinations of local and remote addresses to find ones that actually pass traffic in both directions.
- Picks one. A working pair gets nominated, and media flows over it.
In the browser, all of this runs inside RTCPeerConnection. You give it STUN and TURN servers, you ferry its candidates to the other peer, and it handles everything else. That's convenient, and it's also why ICE failures feel opaque. The interesting activity happens below the API you touch.
How Does ICE Find a Working Path Between Two Peers?
It starts by collecting candidates, the addresses a peer might be reachable at. There are four kinds, and the names show up constantly in logs and stats, so they're worth knowing:
| Candidate type | Where it comes from | What it means |
|---|---|---|
| Host | The device's own network interfaces | A local IP and port. Reachable only by peers on the same network. |
| Server-reflexive | Asking a STUN server what address it sees | The public mapping the NAT assigned. Usable from outside if the NAT cooperates. |
| Relayed | Allocating a port on a TURN server | An address on the relay itself. Reachable by anyone, because the relay forwards the traffic. |
| Peer-reflexive | Noticed mid-check, when a probe arrives from an unexpected address | A NAT mapping neither side predicted. Found by accident, and sometimes the pair that saves the call. |
Every candidate gets a priority score, weighted so that type dominates everything else: host outranks server-reflexive, which outranks relayed. The effect is that ICE prefers the most direct path that works and settles for a relay only when nothing else passes traffic.
Once candidates are exchanged, each agent pairs every local candidate with every remote one and sorts the pairs by priority. Then it works down the list, sending a probe from each local address to its paired remote address. A probe that makes it back proves two things at once: packets can travel that path in both directions, and the responder holds the session credentials from signaling, so a stray or spoofed packet can't fake a working connection.
Both sides run checks, but only one decides. One agent takes the controlling role (the offerer, in a normal browser call), because without a single decision-maker, each side might conclude a different pair is best and send media down two different paths. When the controlling agent is satisfied with a working pair, it repeats the successful probe with a USE-CANDIDATE flag attached. The other side confirms the pair is nominated, and checking winds down.

What's the Difference Between STUN and TURN, and Do You Need Both?
STUN answers one question: what do my packets look like from the outside?
The server reads the source address on your request and sends it back. That address becomes your server-reflexive candidate, and on a lot of home and office networks, it's enough to get a direct connection going.
The catch is that the answer is only useful if the NAT reuses that mapping for other destinations. Stricter NATs, usually called symmetric NATs, create a fresh mapping for every destination, so the address the STUN server saw is one your peer can never reach you at. Firewalls that block UDP kill the direct path too, regardless of what STUN reports.
TURN exists for those networks. Your client authenticates with a relay server, gets allocated an address on it, and both peers send traffic through that address. It works on nearly any network that allows outbound connections at all. The cost is that every media byte now crosses your server, so relay bandwidth lands on your infrastructure bill, and the extra hop adds latency.
| STUN | TURN | |
|---|---|---|
| What it does | Reports your public NAT mapping | Relays your traffic |
| Cost to operate | Minimal | Bandwidth for every relayed byte |
| Authentication | None needed | Credentials required |
| When it's enough | Cooperative NAT behavior | Works when nothing else does |
Do you need both? In production, yes.
Enough real-world networks force a relay that a STUN-only setup shows up directly in your connection failure rate. Treat TURN as core infrastructure: authenticated, monitored, and sized for the traffic share it will actually carry. One detail that simplifies deployment: a successful TURN allocation also reports your public mapping, so a single TURN server covers the STUN job too.
Configuring both in the browser:
12345678910111213const pc = new RTCPeerConnection({ iceServers: [ { urls: "stun:stun.example.net:3478" }, { urls: [ "turn:turn.example.net:3478?transport=udp", "turns:turn.example.net:5349?transport=tcp" ], username: "ephemeral-username", credential: "ephemeral-password" } ] });
If you run your own relay, coturn is the usual choice, and the settings that matter most are the ones that keep it from becoming an open relay anyone can bounce traffic through:
1234567891011121314realm=rtc.example.com use-auth-secret static-auth-secret=replace-with-long-random-secret # TURN behind cloud NAT needs its public/private mapping external-ip=203.0.113.10/10.0.0.5 # Don't let clients relay into your private network denied-peer-ip=10.0.0.0-10.255.255.255 denied-peer-ip=172.16.0.0-172.31.255.255 denied-peer-ip=192.168.0.0-192.168.255.255 user-quota=12 total-quota=1200
How Does Trickle ICE Make Calls Connect Faster?
The original flow was strictly sequential. Gather every candidate, bundle the complete list into the offer, send it, wait for the answer, and only then start testing pairs. Gathering is the slow part, since it includes round trips to STUN and TURN servers, so users sat through all of that before the first probe went out.
Trickle ICE overlaps the phases instead. Each candidate goes to the peer the moment it's discovered, and testing begins as soon as the first pairs exist. Host candidates are available almost instantly, so on a friendly network the connection can be up before gathering has even finished. Browsers do this by default, and the icecandidate event is the mechanism:
12345678910// Sender side: trickle candidates out as they appear pc.addEventListener("icecandidate", (event) => { // null (or an empty-string candidate) signals end-of-candidates signaling.send({ type: "candidate", candidate: event.candidate }); }); // Receiver side: feed them in as they arrive signaling.on("candidate", async (msg) => { await pc.addIceCandidate(msg.candidate); });
Two timing behaviors are worth knowing, because both look like bugs the first time you hit them:
- Probes are paced, not blasted. Checks go out on a fixed interval (50 ms by default), and the pair list is capped, which keeps setup from turning into a traffic burst and keeps ICE from being usable as a packet amplifier against a victim address.
- Failure is deliberately slow. An agent can't give up the moment its pair list is exhausted, because late-arriving peer-reflexive candidates can still rescue the session. The grace period runs about 40 seconds with default timers. So if your calls fail in two seconds, something is cutting ICE off early, and that early exit is the bug to chase.
How Do You Debug ICE Connection Failures?
iceConnectionState tells you that something failed, not why. Watch for checking, connected, and then failed or disconnected when things go wrong, but remember the state string is a one-word summary of everything underneath it. Debugging means checking the layers in order:
- Did gathering produce candidates? No server-reflexive candidates means the client couldn't reach your STUN server. No relayed candidates means the TURN allocation failed, and expired credentials are the usual culprit.
- Did each side get the other's full list? Trickled candidates travel over your signaling channel, so a signaling bug, like dropped messages or a missing end-of-candidates signal, looks exactly like a network problem.
- Which pair got selected? A relayed pair on a network where you expected a direct path means something is blocking UDP or the NAT is stricter than you assumed. Worth tracking in aggregate too, since your relay percentage is a capacity-planning number.
- Is consent still fresh? After connecting, each side keeps re-probing the other every few seconds, and if the answers stop, it has to stop sending within 30 seconds. A call that connects cleanly and dies partway through is often a consent expiry, meaning the path itself broke underneath it.
Only then look above ICE. DTLS, codecs, and the media pipeline can't fail if ICE never completed, so don't start there.
The tooling maps onto those layers:
chrome://webrtc-internals, opened before the call starts, shows the gathered candidates, every checked pair, and the one that got selected.about:webrtcdoes the same job in Firefox and adds a connection log.- The Trickle ICE test page exercises your STUN and TURN URLs in isolation, which cleanly separates "my servers are broken" from "my app is broken."
- getStats() exposes the same data programmatically, which is how the selected pair gets into production telemetry:
1234567891011121314async function selectedPair(pc) { const stats = await pc.getStats(); for (const s of stats.values()) { if (s.type === "candidate-pair" && s.nominated && s.state === "succeeded") { return s; } } } pc.addEventListener("iceconnectionstatechange", () => { if (pc.iceConnectionState === "failed") { pc.restartIce(); // renegotiate with fresh candidates and credentials } });
One thing that isn't a bug: candidate lists full of .local hostnames. Browsers hide local IPs behind mDNS names so websites can't map your internal network, and ICE resolves them behind the scenes.
What This Looks Like When You Build on Stream
Everything above still runs when you build on Stream Video; it's just not yours to operate.
The SDKs negotiate ICE against Stream's edge servers, which are publicly reachable and remove the harder half of the traversal problem. The platform supplies the TURN infrastructure, credentials, and reconnection handling.
The protocol knowledge still pays off in one place: reading stats. Restrictive firewalls and strict NATs don't disappear because the servers are someone else's problem. Knowing what the ICE layer does about them is what turns a vague quality complaint into a specific finding, like a customer site that blocks UDP and needs the relay to run over TCP on port 443.
