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.

Trickle and bulk gathering on a shared millisecond timeline Two swimlanes share one axis from zero to 1200 milliseconds. The trickle lane emits a host candidate at 2 milliseconds, a server-reflexive candidate at 140 milliseconds and a relay candidate at 410 milliseconds, with connectivity checks running from the first candidate and the pair connecting near 300 milliseconds, before gathering has finished. The bulk lane sends nothing until gathering completes at 410 milliseconds, spends time in signalling transit, then runs checks and connects near 900 milliseconds. First 1.2 s of a session β€” trickle connects before bulk has finished gathering Trickle streams out host srflx relay checks running connected β‰ˆ300 ms Bulk waits gathering β€” nothing sent SDP checks running connected β‰ˆ900 ms bulk pays this back on every session 0 200 400 600 800 1000 1200 ms
The same gathering work on both lanes; only trickle overlaps connectivity checks with it.
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
  }
};
Message order for a trickle offer/answer exchange Three lifelines: offerer, signalling channel and answerer. The offerer sends an offer carrying no candidates, then a host candidate at 2 milliseconds and a server-reflexive candidate at 140 milliseconds. The answerer returns an answer and its own candidate. A STUN connectivity check succeeds and the pair reaches connected, after which the relay candidate arrives as an upgrade path and the four second bulk fallback timer is cleared. Offerer Signalling channel Answerer offer SDP β€” no candidates inline candidate host 192.168.1.20 β€” t+2 ms answer SDP candidate srflx 203.0.113.7 β€” t+140 ms candidate srflx from answerer STUN check succeeds β€” connected β‰ˆ300 ms candidate relay 198.51.100.4 β€” t+410 ms, upgrade path 4 s bulk fallback timer cleared β€” gathering finished first
Media flows from the check at β‰ˆ300 ms; the relay candidate arrives afterwards as insurance.

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

  1. Initialise RTCPeerConnection with iceServers pointing at a deliberately high-latency TURN relay so gathering takes long enough to observe.
  2. Intercept onicecandidate, logging each candidate’s candidateType and a timestamp; note how host candidates appear within a few ms while relay candidates lag.
  3. Watch iceGatheringState transition in the console: new β†’ gathering β†’ complete.
  4. Compare a trickle run against a forced-bulk run and record the delta to first connected event.
  5. 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.

Healthy versus stalled trickle console trace The healthy trace logs a host candidate at 2 milliseconds, a server-reflexive candidate at 140 milliseconds, iceConnectionState checking then connected, a relay candidate at 410 milliseconds and finally iceGatheringState complete. The stalled trace logs only the host candidate, enters checking, never receives server-reflexive or relay candidates, drops to disconnected, reports every candidate pair as failed and leaves iceGatheringState stuck at gathering. Same 4 s window, two outcomes β€” read the trace top to bottom Healthy trickle t+2ms candidate host 192.168.1.20 t+140ms candidate srflx 203.0.113.7 iceConnectionState: checking iceConnectionState: connected t+410ms candidate relay 198.51.100.4 iceGatheringState: complete connected fires above the divider β€” before gathering ends Stalled trickle t+2ms candidate host 192.168.1.20 iceConnectionState: checking (no srflx, no relay ever logged) iceConnectionState: disconnected getStats: candidate-pair state failed iceGatheringState: gathering (stuck) nothing below the divider recovers β€” fire the 3–5 s fallback The diagnostic is not the state name: it is whether srflx and relay candidates ever appear at all.
The two traces diverge at the second line β€” a missing srflx candidate, not a bad state transition.

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

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.