Best Practices for ICE Candidate Trickle vs Bulk Gathering
Trickle ICE transmits candidates incrementally via onicecandidate as the agent discovers them; bulk gathering waits for iceGatheringState to reach 'complete' and ships the full SDP in one message. This guide is part of the ICE Candidate Gathering & Filtering guide, and it resolves one decision: which mode to use, and how to fall back safely when your chosen mode stalls. The short answer is trickle in almost every case β it reduces Time-to-First-Frame by 200β800 ms in typical conditions and up to 2β4 s versus bulk on high-latency or relay-heavy paths.
Context & Trade-offs
Bulk gathering is simpler to reason about: you have one complete local description, one signalling message, and no ordering concerns. That simplicity costs latency. Gathering must finish β including TURN allocation, which can take hundreds of milliseconds β before anything reaches the remote peer, and the remote peer cannot begin connectivity checks until it receives that full SDP. On mobile and carrier networks the cost compounds: STUN bindings can refresh in under 30 seconds, so candidates that sat in a bulk SDP waiting for slow gathering may already be stale when the remote peer applies them.
Trickle inverts this. The first host candidate can reach the remote peer within a few milliseconds of setLocalDescription(), connectivity checks start immediately on the cheapest path, and srflx/relay candidates arrive later to upgrade or rescue the connection. The cost is signalling complexity: candidates arrive asynchronously, out of order is possible, and each one must carry its exact sdpMid and sdpMLineIndex.
| Dimension | Trickle | Bulk |
|---|---|---|
| Time-to-First-Frame | 200β800 ms faster | baseline (slowest) |
| Signalling messages | 3β10 per peer | 1 |
| Ordering required | yes (idempotent queue) | no |
| Stale-candidate risk on CGNAT | low | high (>30 s mappings) |
| Best fit | web, mobile, real-time media | legacy SIP gateways, batch signalling |
The marginal extra signalling load is real but small: a typical peer generates 3β10 candidates, and a WebSocket Signaling Implementation delivers each in under 10 ms. Prefer trickle unless your signalling channel genuinely cannot stream.
There is also a hybrid worth knowing: half-trickle. The offerer waits until gathering is complete before sending the offer (so the offer carries every candidate inline), but the answerer trickles. This buys back some of bulkβs simplicity on the offer side while still letting the answerer respond fast. It is mostly a transition tactic for interoperating with a peer that cannot trickle the offer; on a modern stack where both sides trickle, full trickle is strictly better. The one place bulk still earns its keep is a signalling path that batches or serialises messages β for example a store-and-forward gateway that only processes one complete SDP per turn β where streaming candidates would arrive after the gateway has already moved on.
Why the saving exceeds the gathering time
The usual objection to trickle is arithmetic: if gathering finishes in 410 ms, trickle can save at most 410 ms, so why does the field data show 200β800 ms typical and 2β4 s on hard paths? Because bulk serialises three costs that trickle overlaps, and only the first of them is gathering.
The second is signalling transit. A bulk SDP is sent once, after gathering, so its one-way delivery latency lands entirely on the critical path. Trickle sends the offer before any candidate exists, so that same transit is already spent by the time the first host candidate is produced.
The third, and the one engineers routinely underestimate, is check pacing. An ICE agent does not blast every candidate pair at once; it issues one connectivity check per pacing interval (Ta), which libwebrtc keeps at 50 ms by default, to avoid looking like a flood to intermediate NATs. A peer with 5 local and 5 remote candidates over two components produces a check list long enough that simply issuing the checks takes several hundred milliseconds. A failing pair costs far more: the STUN client retransmits on an RTO that starts near 500 ms and backs off, so a pair pointing at an unreachable address is not written off for seconds. Under trickle, that pacing clock starts at t+2 ms with the host pair already queued; under bulk, it does not start until the whole SDP has landed, so every one of those intervals is added to gathering rather than hidden inside it.
Chrome mitigates the gathering component alone through iceCandidatePoolSize in the RTCConfiguration, which pre-warms ICE transports β including TURN allocations β before setLocalDescription() is called. Setting it to 1 while the user sits on a lobby screen typically takes 100β300 ms of allocation off the connect path. Firefox parses the field and ignores it, and it does nothing for the other two costs, so treat it as a supplement to trickle rather than a substitute.
Minimal Runnable Implementation
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// Trickle: forward each candidate the instant it is gathered
pc.onicecandidate = (e) => {
if (e.candidate) {
signaling.send({ type: 'trickle', candidate: e.candidate.toJSON() });
}
// e.candidate === null marks end-of-gathering; do NOT forward it as a candidate
};
// Bulk fallback: if trickle stalls (restrictive NAT, slow TURN), ship the full SDP
const trickleTimeout = setTimeout(() => {
if (pc.iceGatheringState !== 'complete' && pc.localDescription) {
console.warn('Trickle stalled β switching to bulk SDP exchange');
signaling.send({ type: 'offer-complete', sdp: pc.localDescription.sdp });
}
}, 4000); // 3β5 s window before assuming trickle won't finish
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(trickleTimeout);
signaling.send({ type: 'candidates-done' }); // explicit end-of-candidates
}
};
The null candidate event (e.candidate === null) is equivalent to iceGatheringState === 'complete'; either can signal end-of-gathering, but Firefox is more reliable with the state change, so prefer it for the terminal signal.
Queue remote candidates until the remote description exists
Trickleβs one structural hazard on the receiving side is that candidates outrun the description they belong to. The remote peer emits its first candidate within milliseconds of setLocalDescription(), and that candidate can reach you before the offer or answer has been applied β especially when the SDP took a slower route, such as a database write on the way through. addIceCandidate() rejects with InvalidStateError when there is no remote description, and the symptom is rarely a visible crash: it is a silently discarded server-reflexive candidate and a session that falls back to relay or never connects. The fix is a small idempotent queue that drains once the description lands.
let pendingCandidates = []; // candidates that arrived before the remote SDP
async function onRemoteCandidate(json) {
if (!pc.remoteDescription) {
pendingCandidates.push(json); // hold, do not drop
return;
}
// addIceCandidate is safe to call repeatedly; duplicates are ignored by the agent
await pc.addIceCandidate(json).catch((err) => {
console.warn('rejected candidate', json.candidate, err.name);
});
}
async function onRemoteDescription(sdp) {
await pc.setRemoteDescription(sdp);
const queued = pendingCandidates;
pendingCandidates = []; // clear first so a re-entrant message cannot double-apply
for (const c of queued) await pc.addIceCandidate(c);
}
Keep the same queue in place across a renegotiation: an offer collision resolved by rollback will re-run setRemoteDescription() while candidates for the losing description are still in flight, which is the mechanism described in Recovering from Glare in Offer Collisions.
Browser differences that change the trickle contract
Chrome and Edge (Chromium 76 and later) replace host candidates with an mDNS name of the form 4f3a1e6c-....local unless the page already holds a camera or microphone permission. This is a privacy measure that hides the LAN IP from the remote page, and it has a direct trickle consequence: the receiving agent must resolve that name over multicast DNS before it can even build a pair, adding roughly 10β50 ms on a normal LAN and failing outright on networks that block multicast, where the host candidate is effectively dead and the session silently depends on server-reflexive candidates. If your logs show host candidates arriving but never appearing in any candidate-pair, mDNS resolution is the first thing to check.
Firefox emits a candidate with an empty candidate string as its end-of-candidates marker in addition to the eventual null event, so a receiver that forwards anything truthy will ship a meaningless entry to the peer. Filter on e.candidate && e.candidate.candidate !== '' rather than on e.candidate alone. Safari (WebKit 15 and later) follows the specification closely but is the strictest about sdpMid: a candidate whose sdpMid does not match a media section in the currently applied description is rejected rather than heuristically matched by sdpMLineIndex, which makes the queue above mandatory rather than defensive on iOS.
Reproduction Steps & Debugging Log Patterns
- Initialise
RTCPeerConnectionwithiceServerspointing at a deliberately high-latency TURN relay so gathering takes long enough to observe. - Intercept
onicecandidate, logging each candidateβscandidateTypeand a timestamp; note how host candidates appear within a few ms while relay candidates lag. - Watch
iceGatheringStatetransition in the console:new β gathering β complete. - Compare a trickle run against a forced-bulk run and record the delta to first
connectedevent. - Repeat both runs with
iceTransportPolicy: 'relay'so host and server-reflexive candidates are suppressed; this isolates TURN allocation time and shows the worst case your fallback timer has to survive.
Expected healthy trickle log:
// t+2ms candidate host 192.168.1.20
// t+140ms candidate srflx 203.0.113.7
// t+410ms candidate relay 198.51.100.4
// iceConnectionState: checking
// iceConnectionState: connected // long before gathering 'complete'
A stalled session shows iceConnectionState going checking β disconnected instead of connected, and pc.getStats() reports state: 'failed' on every candidate-pair. Read that transition carefully before reacting β the difference between a transient drop and a dead session is covered in Disconnected vs Failed ICE States. Use chrome://webrtc-internals/ to trace nomination timing and confirm iceTransportPolicy is not silently suppressing the candidates you expected; on Gecko the equivalent per-candidate table is described in Diagnosing ICE Failures with Firefox about:webrtc.
Turning the delta into a number you can regress against
A console trace tells you which run felt faster; it does not give you a metric you can alert on. Record the instant of setLocalDescription() as t0, poll getStats() at 1 s intervals, and take the timestamp of the first nominated candidate-pair that reaches 'succeeded'. That difference is the number which should move by 200β800 ms when a cohort switches from bulk to trickle.
const t0 = performance.now(); // set immediately before setLocalDescription()
const probe = setInterval(async () => {
const stats = await pc.getStats();
for (const s of stats.values()) {
// nominated is the pair actually carrying media, not merely a viable one
if (s.type === 'candidate-pair' && s.state === 'succeeded' && s.nominated) {
const local = stats.get(s.localCandidateId);
console.log('connect ms', Math.round(performance.now() - t0), local.candidateType);
clearInterval(probe); // one measurement per session is enough
return;
}
}
}, 1000); // 1 s polling β tighter intervals cost CPU without improving the estimate
Log local.candidateType alongside the timing: a relay pair legitimately connects later β a TURN relay adds 20β40 ms one-way β so averaging relay and host sessions together hides regressions in both. When you are debugging one reproducible failure rather than a fleet, the per-candidate and per-pair tables in Reading chrome://webrtc-internals Dumps give the same timings with nomination order already attached.
Common Implementation Mistakes
- Assuming
onicecandidatefires synchronously withsetLocalDescription. There is an async gap; candidates arrive after gathering begins, so never block waiting for them inline. - Forwarding the
nullcandidate as a real candidate. The null event is end-of-gathering, not a peer candidate β passing it toaddIceCandidateon the remote side throws or no-ops confusingly. - Using bulk on mobile networks. Carrier-grade NAT binding timers can be under 30 s; candidates expire before the remote peer applies them. See WebRTC over CGNAT.
- Ignoring
iceTransportPolicy: 'relay'timing. With host and srflx suppressed, gathering depends entirely on TURN allocation and can run long β your bulk-fallback timeout must account for it. - No fallback at all. A pure-trickle client behind a signalling channel that drops or reorders messages can hang forever; always keep the 3β5 s bulk fallback, and make the socket itself survivable by Reconnecting Signaling Sockets Without Losing Session State so mid-gathering candidates are replayed rather than lost.
- Sending candidates and the offer down different transports. Posting the SDP through an HTTP endpoint while streaming candidates over the socket makes the ordering race permanent rather than occasional, and no queue length saves you if the SDP request is the one that retries. Keep both on the same ordered channel.
- Firing the bulk fallback while trickle is still making progress. A timer that only checks
iceGatheringStatewill re-send a full SDP even when the pair is alreadyconnected, producing a duplicate description the remote peer must renegotiate around. Gate the fallback on bothiceGatheringState !== 'complete'andiceConnectionStatenot yet beingconnectedorcompleted.
FAQ
When should I force bulk ICE gathering over trickle?
Only when the signalling channel cannot handle asynchronous streams β legacy SIP gateways that require a complete SDP before responding, or systems that batch-process signalling. Modern web and mobile apps should default to trickle.
How do I detect that trickle has failed?
Monitor iceConnectionState for 'failed' or 'disconnected' while iceGatheringState stays 'gathering'. Add a heartbeat or timeout on the signalling channel and trigger the bulk fallback or pc.restartIce() (max 3 attempts) if nothing connects within 5β10 s; the safe sequencing for that call, including how to keep existing senders alive, is set out in Triggering an ICE Restart Without Dropping Media.
Does trickle meaningfully increase signalling server load?
Only marginally β 3β10 extra small messages per peer, each delivered sub-10 ms. The 200β800 ms latency win far outweighs it.
Do I still need to send an explicit end-of-candidates signal if I am trickling?
Yes, and skipping it is a common cause of sessions that connect but stay in checking for far longer than they should. Without it the remote agent has no way to know the candidate list is closed, so it keeps the check list open and defers declaring failure on a genuinely unreachable peer. Send the terminal marker when iceGatheringState reaches 'complete' and have the receiver call addIceCandidate({ candidate: '' }), which is the specificationβs end-of-candidates form and is accepted by current Chrome, Firefox and Safari.
Is trickle safe when both peers are behind symmetric NAT and everything ends up on a relay?
It remains the right default, but the shape of the win changes. With host and server-reflexive pairs guaranteed to fail, the first useful candidate is the relay one, so media starts as soon as TURN allocation returns β the same instant bulk would have finished gathering. What you keep is the overlap of signalling transit and check pacing, worth a few hundred milliseconds, plus far better failure visibility. Widen the fallback timer toward the 5 s end of the 3β5 s range here, since no fast candidate arrives early to prove progress.
Related: return to ICE Candidate Gathering & Filtering, or read Traversing Symmetric NAT with TURN and IPv6 Dual-Stack ICE Handling.