Skip to content
WebRTC.wiki

Faster WebRTC call setup with iceCandidatePoolSize

iceCandidatePoolSize gathers STUN and TURN candidates before the offer exists. In Chromium it cut gathering from 456ms to 2ms. It only works once.

4 min readupdated

A good part of the wait before a WebRTC call connects is ICE candidate gathering. Gathering normally starts when you call setLocalDescription, because that is the first moment the implementation knows how many ICE components it needs. Everything after that waits on it: a STUN round trip for the server-reflexive candidate, and for the relay candidate a TURN Allocate, a 401 challenge, and a second Allocate.

On a call your user is waiting for, that whole sequence sits on the critical path. There is one line of configuration that moves it off:

const pc = new RTCPeerConnection({
  iceServers: [...],
  iceCandidatePoolSize: 1,
});

The number is a count of ICE components to pre-gather, not a byte count. One is the normal value for a bundled audio and video call.

What iceCandidatePoolSize does, measured

RFC 9429 section 3.5.4 says the implementation “SHOULD start by checking if any candidates are available in the pool” when setLocalDescription runs, and hand anything it finds to the application immediately.

I measured it against a STUN and TURN server I control, with 150ms of latency added to every response so the round trips are visible. Chromium 151, headless, one data channel, three seconds of idle time between constructing the RTCPeerConnection and calling setLocalDescription. All times are milliseconds from page start.

event pool 0 pool 1
setLocalDescription returns 3068 3067
host candidate 3071 3067
server-reflexive candidate 3223 3068
relay candidate 3524 3068
gathering complete 3524 3069
time spent gathering 456ms 2ms

The server log shows why. With the pool at zero, the first STUN Binding request arrives at the moment of setLocalDescription. With the pool at one, it arrives 76ms after the RTCPeerConnection constructor, and the TURN Allocate follows at 227ms, roughly three seconds before the offer exists.

Scale the 456ms to your own server. It is one STUN round trip plus two Allocate round trips, so it is close to three times the RTT to your TURN server, and none of it is work you can make faster by other means.

It only works once, so ICE restarts get nothing

This is the part that surprised me. RFC 9429 is explicit: “This only occurs for the first offer/answer exchange, after which the candidate pool is emptied and no longer used.”

So it does nothing for reconnection. Same page, same peer connection, pool still set to 1, calling restartIce() five seconds after the first exchange:

event first offer after restartIce()
host candidate +0ms +1ms
server-reflexive candidate +1ms +303ms
relay candidate +1ms +908ms

908ms, which is worse than the 456ms cold path, because the restart gathers on a fresh port and walks the full 401 challenge again. If you are recovering from a network change, the pool is already gone. That is a different problem with a different solution.

The cost: TURN allocations held open

The pooled TURN allocation is a real allocation. My server logged the Allocate at construction time and then a Refresh while the page sat idle, which is exactly what RFC 9429 warns about: “by holding on to these pre-gathered candidates, which will be kept alive as long as they may be needed, the application will consume resources on the STUN/TURN servers it is using.”

Read that as a bill. Every open tab with a pool set holds an allocation and refreshes it, whether or not a call ever happens. Ten thousand idle tabs on a waiting screen is ten thousand allocations, and TURN capacity is usually sized for calls in progress.

There is also a quieter cost. Setting the pool makes the browser contact your STUN and TURN servers the moment the RTCPeerConnection is constructed, before the user has clicked anything. That shows up in network traces, in your TURN logs, and in any privacy review that asks why a page contacts a media server before a call.

When to set iceCandidatePoolSize

Set it when you know a call is imminent and the user is already waiting: a lobby screen, a ringing incoming call, a join button that has been rendered. The pool has to be created early enough to finish gathering, so constructing the peer connection at the same instant you call setLocalDescription buys you nothing.

Leave it at zero for pages where a call is possible but not expected. The saving is bounded by your TURN RTT, the cost is an allocation held for as long as the page is open, and most pages are open far longer than they are in a call.

Trickle ICE does not make it redundant, which is the objection I expected to find. Trickle overlaps gathering with signalling, so the host candidate goes out immediately either way, but the srflx and relay candidates still arrive when the round trips finish. With the pool they are already in hand at setLocalDescription. On my numbers that is 455ms earlier, and the relay candidate is the one that matters on the networks where the call would otherwise fail.

One caveat on all of this: everything above is Chromium 151. The pool is specified behaviour, but “SHOULD” is doing work in that sentence, and I have not measured any other engine. If you ship on Firefox or Safari, measure there before you count on the saving.