ICE Framework Explained: The Brain Behind WebRTC Connectivity

ICE is the part of WebRTC that decides whether there is a call at all. Codecs affect how a call looks; ICE decides whether two machines separated by NATs, firewalls, and corporate policy can exchange a single packet.

Its design choice is worth stating up front, because it explains everything else: ICE does not reason about your network. It guesses, then measures. Rather than classifying your NAT and deducing which path should work — an approach the IETF tried and abandoned — ICE collects every address either side might be reachable at, tries all the combinations, and keeps whichever actually carries traffic. This article covers how those addresses are ranked, how the checks run, and where the process goes wrong.

The four phases

The four phases of the ICE process ICE runs in four stages: phase one gathers host, srflx, and relay candidates; phase two exchanges them with the remote peer over the signaling channel alongside SDP; phase three runs STUN connectivity checks on candidate pairs; phase four selects and nominates the best working pair as the active media path. 1 · Gather candidates host · srflx · relay 2 · Exchange over signaling SDP + trickle ICE 3 · Connectivity checks on pairs STUN binding tests 4 · Select nominate pair best working path ICE CONNECTION PIPELINE
Figure 1 — The ICE state machine, from candidate gathering to the nominated media path.

ICE, specified in RFC 8445, runs as gather → exchange → check → nominate. The phases overlap heavily in practice, which is why timing bugs in this area are so slippery.

Phase 1: gathering, and the four candidate types

A candidate is a transport address at which a peer might be reachable. Four types exist, and one of them is not documented in most introductions:

TypeWhere it comes fromType preference
hostA local network interface. No server involved.126
prflxDiscovered during connectivity checks — see below.110
srflxYour public mapping, reported by a STUN server.100
relayAn address allocated on a TURN server.0

Peer-reflexive candidates are the interesting one. When a connectivity check arrives from a source address neither peer had advertised — typically because a NAT created a fresh mapping for that specific destination — the receiver learns a new address that was not in anyone's candidate list. ICE adds it and can end up selecting it. This is why the path a call finally uses is sometimes an address that never appeared in the SDP at all, which looks like a bug the first time you see it in a log.

Note also that prflx outranks srflx. A peer-reflexive address was proven to work by an actual packet arriving; a server-reflexive one was merely reported by a third party.

Candidate priority is arithmetic, not a heuristic

Every candidate gets a 32-bit priority computed by a formula in RFC 8445:

priority = (2^24) * type_preference
         + (2^8)  * local_preference
         + (2^0)  * (256 - component_id)

Because the type preference is multiplied by 224, it dominates completely: every host candidate outranks every srflx candidate, which outranks every relay candidate, regardless of the other terms. The local_preference term only breaks ties within a type — it is how a browser expresses that Ethernet is preferable to cellular. Chrome extends this with a non-standard network-cost attribute on candidate lines for the same purpose.

The practical reading: relay candidates have type preference 0 by design. ICE will use the relay only when nothing cheaper survives, which is exactly the behaviour you want given what relay bandwidth costs.

Reading a candidate line

Candidates appear in SDP in a fixed format, and being able to read one without looking it up saves real time:

a=candidate:842163049 1 udp 1677729535 203.0.113.7 54321 typ srflx raddr 192.168.1.47 rport 54321
            └────┬───┘ │  │      └────┬───┘ └────┬───┘ └─┬─┘     └─┬──┘ └──────┬─────┘
             foundation │ transport priority   address  port     type    the local address
                    component                                            it was translated from

The raddr/rport pair is present on srflx and relay candidates and tells you which local candidate the address was derived from. Component 1 is RTP; component 2 is RTCP, and you will usually not see it because RTP/RTCP multiplexing is standard now.

Phase 2: exchanging candidates, and why trickle is not optional

Candidates go to the other peer over your signaling channel. The original design had each side gather everything, then send one complete SDP. That meant waiting for the slowest STUN or TURN server to answer or time out before the call could even begin negotiating — routinely several seconds of dead air.

Trickle ICE (RFC 8838) sends each candidate the moment it appears and signals completion separately. Every browser does this by default. Two consequences follow that people learn the hard way:

  • Your signaling channel must stay open after the offer and answer. Candidates arrive for seconds afterwards. Closing the socket once the answer lands is a very common bug, and it is invisible on a good network — host and srflx pairs already succeeded — while breaking exactly the restrictive networks where the late-arriving relay candidate was the only one that would have worked.
  • Candidates can arrive before the remote description is set. addIceCandidate() then rejects. Either buffer them until setRemoteDescription() resolves, or swallow the rejection; both are normal practice.

Gathering is finished when onicecandidate fires with a null candidate, which corresponds to a=end-of-candidates on the wire. Waiting for that before proceeding is precisely the behaviour trickle exists to avoid.

Phase 3: connectivity checks

Each side now forms a checklist: every local candidate paired with every remote candidate of the same component, ordered by a pair priority that deliberately produces the same ordering on both peers:

pair_priority = 2^32 * MIN(G, D) + 2 * MAX(G, D) + (G > D ? 1 : 0)

   G = the controlling agent's candidate priority
   D = the controlled agent's candidate priority

Roles are assigned during negotiation — one agent is controlling and makes the final nomination, the other is controlled. Both compute identical pair priorities, so both work the list in the same order without further coordination.

Each check is a STUN binding request sent directly to the peer, authenticated with MESSAGE-INTEGRITY keyed on the ice-ufrag and ice-pwd from the SDP. This authentication is what stops an attacker who can guess your addresses from injecting checks; it also means a mismatched ufrag — usually from a stale SDP — produces checks that are silently ignored rather than an explicit error.

The checks serve a second purpose beyond validation: sending a request outbound to the peer punches a hole in your own NAT for the reply. This is why checks are bidirectional and why a pair is only usable once both directions have succeeded.

The pair count grows multiplicatively. Three local candidates against three remote ones is nine pairs; adding a second STUN server and a TURN server on each side can push that past thirty. Checks are paced rather than blasted, so more candidates means slower connection setup — a concrete reason not to configure six STUN servers "for redundancy."

Phase 4: nomination

The controlling agent picks a working pair and marks it nominated with a USE-CANDIDATE flag; both sides then use it for media. RFC 8445 specifies regular nomination — check first, nominate once a pair is known good. An older "aggressive nomination" mode that flagged every check was dropped in the update, though you may still encounter it in long-lived native stacks.

Once nominated, the pair keeps exchanging STUN binding requests for the life of the call as consent freshness (RFC 7675). If those stop being answered for long enough, the connection is torn down. This is the mechanism behind a call that appears to die on an otherwise fine network: consent expired because a NAT dropped the mapping.

The two state machines, and which one to watch

WebRTC exposes both iceConnectionState and connectionState, and the difference matters:

iceConnectionStateconnectionState
CoversICE onlyICE and DTLS, aggregated
Reaches connectedWhen a pair is usableWhen the DTLS handshake has also completed
Use forDiagnosing connectivity specifically"Is the call actually up?"

Application logic should watch connectionState. A connection that reaches ICE connected and then never carries media has usually failed its DTLS handshake, and reading only the ICE state hides that entirely.

One value deserves specific mention: disconnected is frequently transient. A brief network change or a few lost consent checks produces it, and the connection often recovers on its own within seconds. Tearing down a call the instant it appears — a very common overreaction — turns a recoverable blip into a dropped call. failed is the terminal one.

ICE restart

When a connection fails or the network changes underneath it — Wi-Fi to cellular, VPN toggled, laptop lid reopened — the fix is not a new peer connection. It is an ICE restart: fresh ufrag and password, fresh gathering, fresh checks, over the existing connection and without renegotiating media:

pc.onconnectionstatechange = () => {
  if (pc.connectionState === 'failed') {
    pc.restartIce();       // then send the new offer through signaling
  }
};

Two cautions. First, this requires the signaling channel to still be alive — another reason not to close it. Second, restarting re-allocates TURN, so expired time-limited credentials cause the restart to fail even though the original call worked. That produces a distinctive signature: calls survive fine until the first network hiccup, then die, and only on long calls.

ICE-lite, and why your SFU is different

A server with a stable public address and no NAT in front of it does not need to gather candidates or run checks. ICE-lite is the reduced mode for exactly that case: the agent advertises only host candidates, never initiates checks, only responds, and is always the controlled agent.

Most SFUs run ICE-lite. It is worth knowing because it changes what you should expect in a capture — when connecting to an SFU, essentially all the ICE work happens on the client side, and an "ICE failure" against an SFU is nearly always a client-side or network-side problem rather than a server one.

Diagnosing ICE from the inside

The authoritative view at runtime is the selected candidate pair, which tells you which path won and therefore what you are paying for:

const stats = await pc.getStats();
stats.forEach((report) => {
  if (report.type === 'candidate-pair' && report.nominated && report.state === 'succeeded') {
    const local = stats.get(report.localCandidateId);
    const remote = stats.get(report.remoteCandidateId);
    console.log(local.candidateType, '→', remote.candidateType,
                'rtt', report.currentRoundTripTime);
    // 'relay' on either side means this call is costing you relay bandwidth.
  }
});

Reported in aggregate, that one line of telemetry gives you your relay ratio, which is the number that drives TURN cost.

When gathering itself is the problem, the question narrows to which candidate types failed to appear — no srflx points at STUN or blocked UDP, no relay points at TURN credentials or a firewalled relay range. Because both depend on the network the browser is sitting on rather than on your code, the check has to run from that network to mean anything. The tester on this site does exactly this in the browser, and the debugging guide covers the full triage order.

Check Your Own Servers

ICE can only pick a path from the candidates your servers hand it. Run your STUN and TURN endpoints through our free tester to see exactly which host, srflx, and relay candidates they produce.

Open the Free ICE Server Tester →