Choosing STUN Server Regions for Latency
A Binding Request is one small UDP datagram out and one back, which makes it tempting to treat resolver placement as a rounding error. It is not: that round trip runs in series with connectivity checks, so whatever propagation delay you hand the ICE agent it pays again before the first frame renders. This guide is part of the STUN Server Deployment Strategies section of the WebRTC Protocol Stack & Signaling Servers guide, and it settles three linked decisions: which regions to stand up, how to order the iceServers array so the fastest reflexive candidate arrives first, and the point at which adding another entry costs more than it buys.
Context & Trade-offs
The only latency variable under your control here is propagation. Server-side processing of a Binding Request is microseconds — coturn parses a 20-byte header, copies the source tuple into an XOR-MAPPED-ADDRESS attribute, and replies. So the reflexive lookup costs exactly one client-to-resolver round trip, and the region question reduces to a distance matrix. The numbers below are typical public-internet RTTs, and they are the input to every placement decision that follows.
Two things fall out of that matrix. First, the win from going single-region to three regions is large and cheap — mean reflexive RTT drops from roughly 108 ms to under 40 ms, which is where the 40–60% reduction in initial connect latency comes from. Second, the win from three regions to six is small: once every population is inside about 30 ms of a resolver, the remaining delay is dominated by DNS, connectivity checks and DTLS, not by the Binding exchange. Budget the fourth and fifth region against a measured row, not a map — a São Paulo row at 121 ms justifies sa-east; a Dublin row at 14 ms against eu-central does not justify eu-west.
Build that matrix from your own traffic rather than from a latency chart. The usable signal is the offset between the first host candidate and the first srflx candidate, bucketed by client region and read at the p50 and p95: the median tells you whether placement is right, and the tail tells you whether steering is right. A region whose p50 is 15 ms but whose p95 is 190 ms does not need another node — it needs its routing fixed, because a minority of clients are landing on the wrong continent. Only when the p50 itself is high across an entire region is a new resolver the answer, and the threshold worth applying is roughly a 60 ms p50, below which the reflexive lookup has stopped being the dominant term in connect time.
Region choice is only half the problem, because the routing layer decides which region a given client actually lands on, and it can be wrong. Geo-DNS resolves on the resolver’s location, not the client’s: a Singapore user on a public DNS service or on DNS-over-HTTPS can be steered to a resolver announced for the wrong continent unless your authoritative servers honour EDNS Client Subnet and the upstream resolver actually sends it. Anycast avoids DNS entirely but substitutes BGP’s notion of “near”, which is path length and policy, not RTT — a client on a transit provider that backhauls Southeast Asian traffic to Tokyo will reach the Tokyo announcement even though Singapore is 11 ms away. Whichever mechanism you pick, make the node say which region it is: run a plain HTTPS endpoint alongside each STUN listener that returns the node’s region string, and sample it from real clients. Without that, mis-steering is invisible, and it shows up only as a diffuse latency tail in ICE Candidate Gathering & Filtering.
Minimal Runnable Implementation
Ordering matters less than most teams assume and more than the specification implies. An ICE agent does not walk iceServers in sequence and stop at the first success — it queries every entry concurrently, so a second STUN URL is not a “fallback”, it is additional simultaneous work. What order does influence is candidate foundation and arrival sequence: candidates from earlier entries are typically gathered and trickled first, so with trickle signalling the remote peer starts checking against your nearest resolver’s reflexive address before the distant one has even replied. Put the routed nearest hostname first, keep at most one public backup, and put relay entries last.
// Build iceServers from a small, ordered list. Rule of thumb: 1 routed STUN
// entry + at most 1 public backup + your relay. Never a per-region list.
function buildIceServers(turnCreds) {
return [
// 1. Nearest node, chosen by anycast or geo-DNS behind ONE hostname.
// First position => its srflx candidate is gathered and trickled first.
{ urls: 'stun:stun.example.net:3478' },
// 2. One public backup only, so a total outage degrades instead of failing.
// Each extra entry adds a DNS lookup plus a full Binding transaction.
{ urls: 'stun:stun.l.google.com:19302' },
// 3. Relay last. TURN allocation is slower and should not delay srflx.
{
urls: ['turn:turn.example.net:3478?transport=udp'],
username: turnCreds.username,
credential: turnCreds.credential
}
];
}
const pc = new RTCPeerConnection({
iceServers: buildIceServers(await fetchTurnCreds()),
// Pre-gathering repeats the whole array per pooled component, so pool size
// multiplies the cost of a long array. Keep the array short before raising this.
iceCandidatePoolSize: 4
});
One cost hides inside that first entry: the hostname still has to be resolved, and on a cold client that DNS lookup is serialised in front of the Binding transaction. With a 30–60 s TTL on a geo-DNS record, a meaningful share of calls start with a full recursive resolution — often 20–40 ms, sometimes more on mobile — before a single STUN packet leaves the device. Warm it. Issue a trivial HTTPS request to the same hostname when the call UI mounts, well before createOffer(), so the resolver answer and often the route are already cached when gathering begins. Anycast sidesteps this entirely for the steering decision but not for the lookup, since the client still resolves a name to the anycast address.
The reason to keep the array short is arithmetic, not taste. The agent runs one Binding transaction per STUN URL per local interface address, so reflexive candidates scale as urls x interfaces, and the connectivity check list scales as the product of local and remote candidates within each address family.
On a dual-stack laptop, four STUN entries produce eight reflexive candidates where two would do — and because several resolvers reflect the same NAT mapping, three of the eight are byte-identical duplicates that the agent must still pair and prune. Add a VPN adapter or a second physical NIC and the interface multiplier grows again, which is why IPv6 Dual-Stack ICE Handling and region count interact. RFC 8445 recommends capping the check list at 100 pairs; a four-entry array on a dual-stack host with a relay entry reaches that ceiling on an ordinary call, and past the cap the agent discards pairs by priority — sometimes the very pair that would have worked. Because candidates are trickled as they arrive, an over-long array also floods the signalling channel at exactly the moment it is busiest, which is worth reading alongside ICE Candidate Trickle vs Bulk Gathering.
Reproduction Steps & Debugging Log Patterns
- Pin a client to a deliberately distant region by putting a single hardcoded regional hostname first in
iceServers— for a European test machine, forcestun-us-east.example.net. Recordperformance.now()atsetLocalDescription()as t0. - Log every candidate with its type and offset from t0 in
onicecandidate, then repeat the run with the routed hostname first. Thehostcandidates should appear at the same offset in both runs; only thesrflxoffset moves. - Read the answering node’s region from the sidecar endpoint on each run and confirm the routing layer actually steered you where you think. A geo-DNS answer that never changes between a VPN-off and VPN-on run means EDNS Client Subnet is not reaching your authoritative servers.
- Diff the two runs on time-to-
connectedfrompc.connectionState, not on gathering completion — gathering finishing late is harmless if the winning pair was already nominated.
// Instrument gathering offsets; run once per candidate region.
const t0 = performance.now();
pc.onicecandidate = ({ candidate }) => {
if (!candidate) return; // null == end of gathering
const ms = Math.round(performance.now() - t0);
// relatedAddress is null for host, set for srflx/relay — handy for grouping.
console.log(`${ms}ms ${candidate.type} ${candidate.address} via ${candidate.url}`);
};
// Expected output, nearest resolver first:
// 9ms host fe80::1c4b ...
// 12ms host 192.168.1.31 ...
// 48ms srflx 203.0.113.44 via stun:stun.example.net:3478
// connectionState: connected (t+190ms)
// Expected output, forced distant resolver:
// 9ms host fe80::1c4b ...
// 12ms host 192.168.1.31 ...
// 298ms srflx 203.0.113.44 via stun:stun-us-east.example.net:3478
// connectionState: connected (t+430ms)
If a run produces no srflx line at all while host candidates appear normally, the resolver is unreachable rather than slow — an unanswered Binding transaction retransmits on a 500 ms base RTO with exponential backoff under RFC 8489, so a dead entry occupies a gathering slot long after the useful candidates have arrived. For per-pair nomination timing rather than per-candidate arrival, dump the session and follow Reading chrome://webrtc-internals Dumps.
Common Implementation Mistakes
- Listing one entry per region. Enumerating
stun-us,stun-euandstun-apin the array is the single most common version of this mistake. Every client then queries all three, pays the worst RTT in extra gathering time, and inflates the check list — the exact opposite of the intended effect. Publish one hostname and let the routing layer choose. - Treating array order as failover. Entries are queried concurrently, not sequentially. A second STUN URL never “takes over” when the first is slow; it just runs alongside it. Order buys you arrival sequence under trickle, nothing more.
- Choosing regions from a customer map instead of from measurements. Account headquarters and packet origin diverge constantly. Drive placement from per-region reflexive-arrival percentiles collected in production, and keep them under continuous watch with Monitoring STUN Binding Success Rates.
- Raising
iceCandidatePoolSizeto mask a long array. Pre-gathering repeats the whole array for each pooled component, so a pool of 10 over four entries schedules dozens of Binding transactions before the user has clicked anything. Shorten the array first, then pool. - Optimising region latency while ignoring the relay path. If reflexive lookups resolve locally but the relay a symmetric-NAT client falls back to sits on another continent, you have moved the delay rather than removed it. Keep relay placement aligned with resolver placement.
FAQ
Does the order of iceServers change which candidate ICE picks?
Not directly. Pair priority is computed from candidate type preference, local preference and the controlling role, so a reflexive candidate from the fourth entry is not penalised for being fourth. What order changes is timing: earlier entries generally gather and trickle first, so the remote peer begins checking their addresses sooner. On a trickle-based stack that timing difference is what the user perceives as connect speed.
How many STUN servers should a client actually be given?
One routed hostname, plus at most one public backup. Two entries on a dual-stack host already yield four reflexive candidates; three or more entries mostly generate duplicates of the same NAT mapping and push the check list toward the 100-pair recommendation without improving reachability. Redundancy belongs behind the hostname — multiple nodes per region — not in the array.
Is anycast or geo-DNS better for picking the nearest region?
For steering accuracy, anycast usually wins because it does not depend on where the client’s DNS resolver sits, and it fails over in under a second instead of waiting out a 30–60 s TTL. Geo-DNS wins on operational cost and debuggability, and it is accurate enough when your authoritative servers honour EDNS Client Subnet. Either way, verify the landing region from real clients — both mechanisms mis-steer quietly, and neither reports it to you.
Related: return to STUN Server Deployment Strategies for the full placement and health-check picture, then read Self-Hosting Coturn STUN vs Public STUN for whether to run the nodes at all, and TURN Server Configuration & Auth for keeping the relay fallback in the same regions.