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.
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.