STUN Server Deployment Strategies
A STUN server does one cheap thing — it reflects a client’s public IP and port back in a Binding Response — but where you place those servers, how you route clients to the nearest one, and how you detect a dead node determine whether ICE gathering completes in tens of milliseconds or stalls long enough to be discarded before SDP exchange. This guide is part of the WebRTC Protocol Stack & Signaling Servers guide. The goal here is a concrete, multi-region STUN deployment: geographic placement that keeps reflexive lookups local, a routing layer that steers each client to its closest resolver, an anycast-versus-unicast decision grounded in UDP behaviour, and health checks that validate actual Binding cycles rather than TCP liveness.
STUN is stateless and almost free to run, which tempts teams to treat it as an afterthought and point everyone at stun:stun.l.google.com:19302. That works until you measure tail latency: a client in Singapore querying a US-East resolver adds 180–220 ms of round-trip time to a step that should cost single-digit milliseconds, and that delay lands directly on the critical path of ICE Candidate Gathering & Filtering. Multi-region placement cuts initial connect latency by 40–60% precisely because the server-reflexive (srflx) candidate is the one most ICE agents end up nominating on networks where direct host paths fail but the NAT is not symmetric.
Step 1 — Geographic placement of resolvers
Place STUN resolvers in the same regions where your users — and your media path — already live. The reflexive lookup itself is one UDP round trip, so the only latency you control is propagation: a resolver within 30 ms of the client is the target. Three regions (a US point of presence, a European one, and an Asia-Pacific one) cover the majority of global traffic; add a fourth in South America or India only when your analytics show a concentrated population paying a 150 ms+ penalty. Turning that population data into an actual region list is a measurement exercise rather than a guess, and the method is worked through in Choosing STUN Server Regions for Latency.
Co-locate STUN with the rest of your edge where it makes sense. If you also run TURN relays — see TURN Server Configuration & Auth — putting the STUN listener in the same region keeps the fallback path coherent: a client that fails srflx and escalates to a relay does not suddenly cross an ocean. Bind each node to dual-stack IPv4/IPv6 interfaces so mobile clients on IPv6-only carriers still gather a usable reflexive candidate.
# coturn STUN-only listener, one node per region
listening-port=3478
# Advertise the node's public address, not the cloud-internal RFC 1918 IP
external-ip=203.0.113.21
no-tls # STUN needs no TLS; drop the listener to shrink attack surface
no-tcp # STUN binding requests are UDP-only
no-auth # plain STUN (RFC 8489) is unauthenticated by design
no-cli # disable the telnet admin console in production
Keep resolvers stateless. A STUN Binding exchange carries no session, so any node can answer any client — that property is what lets you scale horizontally behind a load balancer or anycast prefix without session affinity. The moment you add connection tracking you have broken the assumption that makes STUN cheap.
Size each node for request rate, not bandwidth. A STUN Binding is one small UDP datagram in and one out, so a modest instance answers tens of thousands of lookups per second; the constraint is packet-per-second handling and the kernel’s UDP socket buffers, not CPU or link capacity. This is the opposite of TURN, where each relayed session consumes sustained media bandwidth. Because the workload is so light, the right granularity is one small node (or a small autoscaling group) per region rather than a few large central boxes — placement near users buys more than vertical scale ever will.
Dual-stack placement and IPv6-only carriers
Binding each node dual-stack is not a checkbox item; it changes which candidates the agent can form at all. An ICE agent gathers a separate reflexive candidate per local address family, and it can only gather a v6 reflexive candidate if the resolver it reaches answers on v6. A node listening only on IPv4 leaves an IPv6-only mobile client with nothing but mDNS-obfuscated host candidates and, where the carrier runs 464XLAT, a reflexive address that belongs to the translator rather than to the handset — an address that changes as the translator rebalances. Publish both an A and an AAAA record for the routed hostname, and expect the agent to try both families; the pairing and priority ordering that follows from a dual-stack candidate set is worked through in IPv6 Dual-Stack ICE Handling.
# Dual-stack STUN listener on one regional node
listening-port=3478
listening-ip=203.0.113.21 # IPv4 listener
listening-ip=2001:db8:42::21 # IPv6 listener on the same process
external-ip=203.0.113.21 # public v4 mapping advertised to clients
external-ip=2001:db8:42::21 # public v6 mapping; omit it and v6 clients get nothing usable
Two operational details follow from this. First, in cloud environments the v6 address is usually globally routable with no NAT in front of it, so external-ip for the v6 line is the interface address itself — copying the v4 pattern of “public address differs from bound address” and inventing a translated v6 address produces a resolver that reflects an address nothing routes to. Second, your health prober must exercise both families as separate checks. A node that is v4-healthy and v6-wedged passes a naive probe while quietly halving the candidate set for a growing share of mobile traffic, and the symptom surfaces only as a slightly elevated connection-failure rate on one carrier.
Step 2 — Minimising latency: routing clients to the nearest node
Geographic placement only pays off if each client actually reaches its closest resolver. Two routing mechanisms achieve this, and they apply at different layers.
GeoDNS resolves a single hostname (stun.yourdomain.com) to the regional IP nearest the client’s resolver. It is simple and works with the standard iceServers array, but it inherits DNS caching: a client on a misconfigured resolver, or one using a public DNS service far from its physical location, can be steered to the wrong region. Keep the TTL low (30–60 s) so failover is timely without hammering your authoritative servers.
The alternative — anycast, covered in depth in Step 3 — advertises one IP from every region and lets BGP pick the closest. Either way, the client config stays trivial: list one or two STUN URLs and let the routing layer resolve them. Do not list five regional hostnames in iceServers; browsers cap the ICE candidate pool, and every extra endpoint adds DNS resolution time and srflx candidates that compete for nomination without improving connectivity.
// Client config points at ONE routed hostname, not a hardcoded region.
// The routing layer (anycast or GeoDNS) selects the nearest node.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.yourdomain.com:3478' }, // nearest node, resolved at runtime
{ urls: 'turn:turn.yourdomain.com:3478', // co-located relay fallback
username: creds.username, credential: creds.credential }
],
iceCandidatePoolSize: 10 // pre-gather srflx candidates before the call starts
});
Pre-gathering with iceCandidatePoolSize warms the reflexive lookup ahead of createOffer(), so the round trip to the nearest resolver overlaps with signalling rather than serialising after it. On a well-placed node this removes the STUN round trip from the perceived connect path entirely. The full exchange is four messages: resolve the routed hostname, send one Binding Request, read the mapped address back, then trickle the resulting candidate to the peer.
When pre-gathering actually fires
iceCandidatePoolSize is easy to set and easy to waste, because the pool is allocated from the configuration the RTCPeerConnection holds at construction time. Pass iceServers in the constructor, as above. If you construct the connection with an empty configuration and supply servers later through setConfiguration() — a common pattern when TURN credentials are fetched asynchronously — Chrome has nothing to pre-gather against at construction, and the reflexive lookup ends up back on the critical path after createOffer(). Fetch the credentials first and construct once; a credential fetch that costs 40 ms of HTTP is cheaper than a serialised STUN round trip plus the gathering timer behind it.
Browser support is uneven enough that pooling cannot be your only latency strategy. Chrome implements the pool and reuses the pre-gathered ports; Firefox parses iceCandidatePoolSize but does not pre-gather from it, so on Firefox the entire saving has to come from emitting candidates as they arrive rather than waiting for gathering to finish — the difference between trickling and a bulk end-of-candidates wait is 200–800 ms in the good case and 2–4 s when a resolver is slow, quantified in ICE Candidate Trickle vs Bulk Gathering. Treat pooling as an optimisation layered on top of trickle, never as a replacement for it.
The other reason nearest-node routing matters more than it appears is that the agent does not wait indefinitely for a slow resolver. Gathering is bounded by an internal timer, and a Binding Request that goes unanswered is retransmitted on a doubling backoff. If the first response arrives after the agent has already declared gathering complete, the srflx candidate is simply never produced — there is no error surfaced to your application, only a candidate list that is one entry shorter than you expected and a connection that falls back to relay or fails outright. A resolver 200 ms away does not make ICE 200 ms slower; on an unlucky client it removes reflexive connectivity entirely.
Step 3 — Anycast vs unicast topology
Anycast announces the same IP from multiple physical locations; the network routes each packet to the topologically nearest announcement. For STUN this is attractive because a single iceServers entry transparently resolves to a local node, with sub-second failover when a region withdraws its route — no DNS TTL to wait out.
The caveat is UDP statelessness, and for STUN it happens to be a non-issue. A STUN Binding is request/response: the client sends one packet, gets one back, and the exchange is complete. Even if BGP reconverges mid-flight and a retransmit lands on a different node, that node can answer it identically because no node holds session state. This is exactly why anycast pairs cleanly with STUN but is dangerous for TURN, where an allocation is a long-lived stateful flow that must stay pinned to one server.
Unicast (distinct IPs per region, fronted by GeoDNS) is the pragmatic default for teams without their own anycast prefix and BGP relationships. It is operationally simpler, debuggable with a plain dig, and good enough when your TTLs are short.
| Property | Anycast | Unicast + GeoDNS |
|---|---|---|
| Client config | One IP, network-routed | One hostname, DNS-routed |
| Failover speed | Sub-second (BGP withdraw) | Bounded by DNS TTL (30–60 s) |
| STUN suitability | Excellent (stateless req/resp) | Excellent |
| TURN suitability | Poor (stateful allocations) | Acceptable with pinning |
| Operational cost | High (BGP, PI space) | Low (managed DNS) |
| Debuggability | Harder (path-dependent) | Easy (dig, traceroute) |
Most deployments start unicast and graduate to anycast only when failover latency or per-region DNS skew becomes a measured problem.
It is worth spelling out precisely why a mid-flight reroute is harmless, because that property is what the whole topology rests on. The mapped address a resolver returns is not looked up in any table — it is read straight off the UDP header of the request as it arrived, XORed with the magic cookie, and written into the response. Any node in the anycast set therefore computes the same answer for the same client, because the address being reported is a property of the client’s NAT binding and of the destination IP, and every node in the set shares that destination IP. Responses are matched to requests by a 96-bit transaction ID carried in the response, so even a duplicate answer from a second node after reconvergence is either matched and used or discarded as stale — never misattributed to another transaction.
What anycast genuinely does not survive is a route that oscillates faster than a single transaction completes, because a client whose retransmit lands on a node in a different region gets a correct answer with a wildly different RTT, which perturbs the latency figures your prober is trying to hold nodes to. Flap damping on the announcement, not application logic, is the fix. The second real constraint is diagnosis: when a user reports slow gathering you cannot tell from the client which node answered, so put the region into the response path deliberately — a per-region log line keyed on the client’s mapped address, or a separate per-region debug hostname on unicast IPs that support can hand out. Without that, an anycast STUN incident is an exercise in guessing which point of presence is misbehaving.
A practical hybrid avoids choosing globally: run anycast for STUN where you already have the prefix and BGP relationships, and keep TURN on distinct unicast IPs with session pinning. Because STUN and TURN are answered by the same coturn binary, you can still co-locate them on one host per region — you simply announce the STUN listener into the anycast prefix and bind the TURN listener to a region-specific address that GeoDNS or explicit per-region hostnames resolve. The client then gathers reflexive candidates from the network-routed STUN IP and, only on symmetric-NAT fallback, allocates a relay on the pinned TURN address.
Step 4 — Health checks and verification
A STUN node can pass a TCP connect check and still be useless: the process may be alive while the UDP listener is wedged or returning a private address. Health checks must validate a real Binding Request/Response cycle and assert that the mapped address is public, then deregister failing nodes from the routing layer.
#!/bin/bash
# Validate each resolver behind the routed hostname with a real STUN exchange.
# Requires stun-client (apt install stun-client) — checks the Mapped Address.
for host in $(dig +short stun.yourdomain.com A); do
out=$(stun-client --mode full --localport 0 "$host" 3478 2>&1)
if echo "$out" | grep -q "Mapped address"; then
addr=$(echo "$out" | grep "Mapped address" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+')
# Reject RFC 1918 leaks — a node returning a private IP is misconfigured.
if [[ "$addr" =~ ^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.) ]]; then
echo "UNHEALTHY (private mapped addr $addr): $host" # deregister
else
echo "HEALTHY ($addr): $host"
fi
else
echo "UNHEALTHY (no response): $host" # deregister
fi
done
Drive this from your load balancer or an external prober on a 10–15 s interval, and export coturn’s Prometheus counters so you can alarm on request rate, dropped packets, and 4xx error responses; turning those counters into a per-region success-rate signal is covered in Monitoring STUN Binding Success Rates. To verify end-to-end from a browser, open chrome://webrtc-internals, start a connection, and confirm srflx candidates appear with the expected public address and a gathering time well under your ICE timeout. A node returning candidates 200 ms+ late is effectively unhealthy even if it answers — those candidates risk being discarded before SDP exchange.
The prober therefore has three distinct verdicts rather than two, because “answers, but with the wrong address” and “answers, but too late” both need to pull the node out of rotation:
Failure mode: the silently rate-limited resolver
The nastiest STUN failure is not a dead node — it is a healthy node that refuses a subset of clients. Rate limiting is mandatory (an open resolver is an amplification vector), but a quota expressed per source IP interacts badly with NAT: every client behind one corporate gateway or one carrier-grade NAT shares a single source address, so a 20-requests-per-second limit that looks generous per user becomes a hard cap for an entire office. The symptom is distinctive and easy to misread as a client bug: connections succeed for most users, fail in bursts at the top of the hour when a large meeting starts, and the failures concentrate in one network. A prober running from your monitoring VPC sees a perfectly healthy node throughout, because it is a different source address with its own quota.
Diagnose it from both ends. On the client, capture a session with a failing user and check whether the candidate list contains host and relay entries but no srflx — a missing reflexive candidate with a working relay is the signature of a request that was dropped rather than answered, and the candidate table plus the gathering timeline in Reading chrome://webrtc-internals Dumps shows it directly. On the server, coturn’s --denied-peer-ip and quota counters log the drop, and tcpdump -i any -n 'udp port 3478 and host <gateway-ip>' shows requests arriving with no responses leaving. The fix is to size the quota against realistic NAT concurrency rather than per-user intuition — a few hundred requests per second per source IP still stops amplification, since an attacker needs sustained volume to be useful — and to exempt known corporate egress ranges outright. If you inherit a quota you cannot raise, the safety net is a second iceServers entry pointing at a resolver on a different address, which costs one extra candidate and removes the single-quota dependency.
Health and routing must be wired together or the check is cosmetic. With GeoDNS, a failing prober should remove the regional A record (respecting the 30–60 s TTL) so new clients resolve to a healthy neighbour. With anycast, deregistration means withdrawing the BGP announcement from the failing node so the network reroutes to the next-nearest region within a second. Either way, run the prober from multiple vantage points: a node can be reachable from your monitoring VPC but blackholed from a particular carrier, and a single-origin check will miss it. Capture a packet trace with tcpdump -i any -n 'udp port 3478' when a node flaps to confirm whether requests arrive and responses leave — that distinguishes a wedged listener from an upstream routing problem.
Edge Cases & Browser Quirks
- Candidate pool caps differ. Chrome and Firefox cap the number of candidates gathered; listing many regional STUN hostnames inflates DNS resolution time and produces redundant srflx candidates rather than better connectivity. One routed hostname is correct.
- Firefox mDNS obfuscation. Firefox (and Chrome) replace
hostcandidate IPs with.localmDNS names by default, which makes thesrflxcandidate from STUN even more important — it is often the first globally routable candidate the remote peer can use. - Safari gathering timeout. Safari is less tolerant of slow STUN responses and may finish gathering before a distant or rate-limited resolver replies, silently dropping that srflx candidate. Nearest-node routing is what keeps Safari from skipping STUN entirely.
- Symmetric NAT defeats STUN regardless of placement. On a symmetric NAT the port mapping changes per destination, so the reflexive address STUN learns does not match the address used to the peer. No amount of geographic tuning fixes this — a TURN relay is the mandatory fallback, which is why traversing symmetric NAT with TURN is the companion path. Mobile and carrier-grade NAT bindings also refresh in under 30 s, so a candidate gathered too early can expire — the refresh cadence and its effect on gathering are unpacked in WebRTC over CGNAT.
- MTU on carrier networks. Keep STUN responses under 1280 bytes; some carrier paths fragment larger UDP datagrams unreliably, dropping the response and forcing a retransmit that costs another round trip.
- Network handover re-gathers against whatever node it now reaches. When a handset moves from Wi-Fi to LTE the old reflexive candidate is dead — the NAT binding it described no longer exists — and an ICE restart gathers fresh candidates from the new interface, which may resolve your routed hostname to a different region because the carrier’s DNS resolver sits elsewhere. Budget for the second lookup in your reconnection timeout rather than assuming the cached candidate survives; the full handover sequence is in Handling Wi-Fi to Cellular Network Handover.
iceTransportPolicy: 'relay'skips STUN entirely. Applications that force relay for IP-privacy reasons never send a Binding Request, so STUN health has no bearing on their connection rate and all of the load moves to TURN. If you enable relay-only for a subset of users, exclude them from any dashboard that correlates STUN success with overall connectivity, or the numbers will look inexplicably decoupled.- Chrome caches the hostname beyond the DNS TTL. The browser’s own resolver cache, plus the OS cache underneath it, means a client that has already resolved
stun.yourdomain.comcan keep hitting a withdrawn node past your 30–60 s TTL. Long-lived tabs are the worst case. This is an argument for anycast in deployments where regional failover time is actually measured, not merely specified.
Common Implementation Mistakes
- Single-region or single-instance STUN. One node, or one region, is a single point of failure on the critical path of every connection and adds cross-continent latency for half your users. Deploy per-region with a routing layer in front.
external-ipleft at the cloud-internal address. Behind a cloud NAT gateway the node advertises an RFC 1918 mapped address, so every client receives a useless private srflx candidate. Setexternal-ipto the public address and verify with the health check above.- Routing STUN through a TCP load balancer. STUN Binding requests are UDP; a TCP LB drops or mangles them. Use a UDP-aware L4 balancer with Direct Server Return to preserve the source address, or rely on anycast/GeoDNS.
- TCP-style health checks. A port-open check reports a wedged UDP listener as healthy. Probe with an actual Binding exchange and assert a public mapped address.
- No TURN fallback. STUN cannot traverse symmetric NAT. Shipping STUN-only guarantees connection failures for users behind symmetric or carrier-grade NAT; always pair it with a relay. On corporate networks that block outbound UDP entirely the Binding Request never even reaches your resolver, so the relay has to listen where the firewall allows — see Forcing TURN over TCP 443 on Locked-Down Networks.
- No rate limiting. STUN responses are slightly larger than requests, making open resolvers a UDP amplification vector. Apply per-source request quotas even though plain STUN needs no authentication.
- Blocking ICMP on the STUN node. A blanket ICMP drop rule breaks path-MTU discovery toward your resolver and hides the
port unreachablethat would otherwise tell a client instantly that the listener is gone, converting a fast failure into a full retransmit backoff. Permit ICMP type 3 and ICMPv6 packet-too-big at minimum. - Deploying STUN and TURN with the same autoscaling policy. STUN scales on packets per second and can be replaced at any moment; TURN scales on sustained relay bandwidth and cannot be terminated without dropping live media. Sharing one scaling group means either over-provisioning STUN or killing relay sessions on scale-in.
FAQ
How many STUN regions do I actually need? Three — North America, Europe, and Asia-Pacific — cover most global traffic and keep nearly all users within a 30 ms reflexive round trip. Add a fourth region only when analytics show a concentrated population paying a 150 ms+ penalty. Per client, expose one routed hostname rather than several regional ones.
Should I run my own STUN at all, or just use public servers?
It depends on reliability and privacy requirements. Public resolvers like stun.l.google.com are free but offer no SLA, no latency guarantee near your users, and unannounced rate limits. The full trade-off — including a minimal coturn STUN-only config — is in self-hosting Coturn STUN vs public STUN servers.
Can I run STUN and TURN on the same coturn process? Yes — coturn answers STUN Binding requests on the same listener whether or not TURN relaying is enabled. But the deployment topologies diverge: STUN scales statelessly behind anycast, while TURN allocations are stateful and must pin to one node. For a relay-grade config see configuring Coturn for production TURN relay.
Why does anycast work for STUN but not for TURN? A STUN Binding is a single stateless request/response, so any node can answer any packet and BGP reconvergence is harmless. A TURN allocation is a long-lived stateful flow; if anycast reroutes mid-session to a node that holds no allocation state, the relay breaks. STUN is the stateless half of the same coturn binary.
Does the STUN server see or carry any media? No. The resolver participates only in candidate gathering: it receives one Binding Request per local address and returns one response, then plays no further part. Media and the connectivity checks between peers never touch it — the periodic Binding Requests that keep an established connection alive are exchanged directly between the two agents, not with your server. The privacy consequence is narrow but real: your logs contain each client’s public IP and port at the moment of gathering, so treat them with the same retention policy as any other address log, and remember that a node returning a mapped address is the only reason the far peer learns that address at all.
How do I take a STUN node out of service without breaking live calls? Because the exchange is stateless, an established call does not depend on the node that served it — you can terminate a resolver mid-call with no effect on media. The only sessions at risk are those gathering right now, and those that later trigger an ICE restart. So the drain procedure is simply: remove the node from the routing layer, wait one gathering window plus the DNS TTL (call it 90 s on a 30–60 s TTL), then terminate. Keep at least two nodes per region so the withdrawal never leaves a region unserved, and stagger regional maintenance so no more than one region is degraded at a time.
Related: this guide sits under WebRTC Protocol Stack & Signaling Servers; pair it with self-hosting Coturn STUN vs public STUN servers, TURN Server Configuration & Auth, and ICE Candidate Gathering & Filtering for the full NAT-traversal path.