skip to content

In WebRTC, what starts ICE candidate gathering on an RTCPeerConnection, how does each candidate reach the application, and how is the end of gathering signalled?

level: middleimportance: should knowfreq 20%

answer

  1. the local description sets the count
  2. one event per candidate
  3. empty string versus null
  4. new, gathering, complete
  5. url and relayProtocol on the candidate

basics

~20 s

Applying a local description with setLocalDescription starts a gathering phase. Each host, server-reflexive or relay candidate then arrives in an icecandidate event; an empty candidate string marks one transport's end-of-candidates, and iceGatheringState reaching complete marks the whole connection's.

solid answer

~50 s

JSEP (RFC 9429) starts a gathering phase when the local description adds a new or recycled `m=` section or carries new ICE credentials, so in practice `setLocalDescription` starts it; `iceCandidatePoolSize` can pre-gather for the first exchange. `iceGatheringState` moves from `new` to `gathering` to `complete`. Each candidate arrives in an `icecandidate` event as an `RTCIceCandidate`: the SDP `candidate` string, `sdpMid`, `sdpMLineIndex`, `usernameFragment`, a `type` of `host`, `srflx` or `relay`, the server `url` for reflexive and relay candidates and `relayProtocol` for relay ones. An event whose `candidate.candidate` is an empty string is one transport's end-of-candidates (RFC 8838) and is forwarded to the peer; a final event with `candidate` null means the whole connection finished and is not forwarded. Gathered candidates are also written into `localDescription`, so an application that does not trickle waits for `complete` and sends the whole description.

code

pseudocode · 13 lines
pseudocode
on icecandidate(event):
    if event.candidate == null:
        // whole connection finished gathering (legacy signal): send nothing
        return
    if event.candidate.candidate == "":
        // end-of-candidates for one transport: forward it like a candidate
        signal(event.candidate.toJSON())
        return
    record(event.candidate.type, event.candidate.url, event.candidate.relayProtocol)
    signal(event.candidate.toJSON())

on icecandidateerror(event):
    record(event.url, event.errorCode, event.errorText)   // 701: server unreachable

go deeper

for a junior

Recall that setLocalDescription starts gathering and that candidates arrive one by one in icecandidate events.

for a middle

Explain the three meanings of icecandidate, the gathering states, and what the url and relayProtocol members reveal about where a candidate came from.

for a senior

Show how you would instrument gathering in production: log candidate types and icecandidateerror codes so missing relay candidates are visible before users complain.

for a principal

Judge when pre-gathering with a candidate pool is worth the server load it adds, against the setup latency it removes for incoming calls.

## What starts a gathering phase An **ICE candidate** is one transport address (IP address, port and protocol) at which this endpoint might receive packets. **Gathering** is the work of finding them: reading local interfaces, asking STUN servers for reflexive addresses and asking TURN servers for relays. JSEP, the browser API model in RFC 9429 (which obsoletes RFC 8829), says a **gathering phase** begins when: - the local description adds a new or recycled `m=` section, because that tells the agent how many ICE components need candidates; or - the local description carries new ICE credentials, which is an **ICE restart**. In practice that means `setLocalDescription`. Two refinements: - `iceCandidatePoolSize` (default 0) lets the agent pre-gather before any description exists; the pool is used only for the first offer/answer exchange and then emptied. - The `iceTransportPolicy` in force is read at the start of each phase, so a policy change applies only to the next one. ## What one candidate looks like Each candidate reaches the application inside an `icecandidate` event as an `RTCIceCandidate`: | Member | Meaning | |---|---| | `candidate` | the SDP candidate attribute, without the `a=` prefix | | `sdpMid`, `sdpMLineIndex` | which `m=` section it belongs to | | `usernameFragment` | the ICE ufrag, tying it to one generation of credentials | | `type` | `host`, `srflx`, `prflx` or `relay` | | `url` | for `srflx` and `relay`, the STUN or TURN server it came from | | `relayProtocol` | for `relay`, `udp`, `tcp` or `tls` toward the TURN server | A typical first-generation set for one component looks like this: ``` candidate:1 1 UDP 2130706431 192.0.2.10 54400 typ host candidate:2 1 UDP 1694498815 203.0.113.7 61002 typ srflx raddr 192.0.2.10 rport 54400 candidate:3 1 UDP 16777215 198.51.100.20 49170 typ relay raddr 203.0.113.7 rport 61002 ``` The priorities follow RFC 8445's formula, `2^24 × type preference + 2^8 × local preference + (256 − component ID)`, with the recommended type preferences 126 for host, 100 for server-reflexive and 0 for relayed; with a local preference of 65535 and component 1 that gives the three numbers above. A **peer-reflexive** candidate (type preference 110) is never fetched from a server: it is learned during connectivity checks, when a check reveals an address neither side signalled. ## The three meanings of `icecandidate` The W3C specification uses one event for three indications: 1. **A candidate** — `event.candidate` is populated. Send it to the far peer, which passes it to `addIceCandidate`. 2. **End of one generation for one transport** — `event.candidate.candidate` is the **empty string**. This is the end-of-candidates indication of RFC 8838; forward it like a candidate so the far agent stops waiting. 3. **Everything finished** — `event.candidate` is **null**. This exists for backward compatibility, equals `iceGatheringState` becoming `complete`, and is **not** sent anywhere. `iceGatheringState` itself is `new`, `gathering` or `complete`, aggregated over all the connection's ICE transports. ## Trickling versus waiting Every gathered candidate is also added to the current or pending local description, which allows two styles: - **Trickle** (RFC 8838): send the offer at once and stream candidates as they surface. Setup is faster because checks begin before slow servers answer. - **Wait**: wait for `complete`, then send `localDescription`, which now lists every candidate. Simpler, but setup takes as long as the slowest STUN or TURN server. How the candidates travel to the far peer is the signalling channel's job, which the application provides. ## Reading gathering in production What the candidate list lacks is usually the diagnosis: - **No `srflx` candidate:** the STUN server is unreachable or outbound UDP is blocked. - **No `relay` candidate:** the TURN credential was rejected (an `errorCode` of 401) or the server could not be reached (701). - **Candidates for only one `m=` section:** expected with BUNDLE; JSEP gathers for a bundled section only when its MID is the BUNDLE tag, so one set serves every section. - **Gathering that never reaches `complete`:** a server that neither answers nor errors keeps the transport gathering until the implementation's timeout. ## When a server does not answer If a STUN or TURN server fails, gathering continues without its candidates and the connection fires `icecandidateerror` with the server `url`, a STUN `errorCode` and `errorText`. Code **701**, outside the STUN range, means no local address could reach the server, and that error fires only once per server URL during gathering.

  • Where do peer-reflexive candidates come from, if gathering only queries STUN and TURN servers?
    From connectivity checks, not from gathering. When a STUN check arrives from, or a check response reports, an address that neither side signalled, the ICE agent learns it as a peer-reflexive candidate. RFC 8445 gives it type preference 110, between host (126) and server-reflexive (100), because it was discovered on the path the peers actually use.
  • What does iceCandidatePoolSize buy, and when is it wasted?
    It lets the agent gather before `setLocalDescription`, so an application expecting an incoming call can answer without waiting on STUN and TURN servers. JSEP uses the pool only for the first offer/answer exchange and then empties it. If the call never comes, the server queries and any relay allocations the pool made were spent for nothing.

saying these in an interview costs you the question

  • Constructing an RTCPeerConnection always starts gathering, whatever the configuration.
  • A null candidate and an empty-string candidate both mean the same thing.
  • The browser obtains peer-reflexive candidates by asking the STUN server.
  • A non-trickling application can send its offer before gathering completes and lose nothing.
  • Each locally gathered candidate must also be passed to the local addIceCandidate.