Monitoring STUN Binding Success Rates
A STUN node fails quietly. There is no connection to drop, no 5xx to count, and no session to expire β a Binding Request that gets no answer simply produces one fewer candidate in one browser, and the call either survives on a host pair or falls back to relay while the user notices nothing except a slower join. This guide is part of the STUN Server Deployment Strategies section of the WebRTC Protocol Stack & Signaling Servers guide, and it answers one operational question: what do you measure, from where, and at what threshold do you page someone when a region stops answering on UDP 3478?
Context & Trade-offs
The only metric that means anything is server-reflexive candidate yield: the fraction of gathering attempts that produced at least one srflx candidate, measured on the client, segmented by region and by network. Everything else is a proxy. A process-liveness check tells you turnserver is running; it does not tell you that a security-group edit two hours ago dropped inbound UDP while leaving your TCP health check green. A packets-received counter on the node tells you what arrived; it cannot count what a blackholed path silently discarded upstream.
Server-side telemetry is worth collecting but is structurally blind to the failure you care about. Coturnβs Prometheus exporter is relay-oriented β allocations, sessions, traffic bytes β and a STUN-only listener creates none of those. Its interesting counters sit near zero whether the node is perfectly healthy or completely unreachable, so a dashboard built on them looks identical in both states. Client-side yield is the inverse: it observes the exact path a real user takes, including the carrier, the corporate firewall, and the routing layer that chose the node.
Yield is not a single number, though, and treating it as one is how teams end up either paging at 3 a.m. for a locked-down enterprise office or missing a genuine regional outage inside statistical noise. Segment it before you threshold it.
| Population | Healthy srflx yield | Notes |
|---|---|---|
| Consumer broadband, same-region node | 98β99.5% | The reference population; anything lower is your node |
| Mobile / CGNAT | 97β99% | Bindings refresh in under 30 s, so slow answers also cost you |
| Enterprise with UDP egress filtering | 0β40% | Correct behaviour, not an outage β these clients need TURN |
| Fleet-wide, all networks blended | 96β98% | Useful as a trend, useless as a page trigger |
The practical consequence: build the alert on the reference population and exclude the rest explicitly. Sessions with iceTransportPolicy: 'relay' never gather reflexive candidates at all, and sessions that produced zero host candidates never got as far as a Binding Request β both belong outside the denominator, not inside it as silent failures.
Yield alone still misses half the story, because a node can answer every request and be too slow to be useful. Track time-to-first-srflx alongside it: measured from setLocalDescription(), a same-region self-hosted node should land a p50 under 30 ms and a p95 under 150 ms. A p95 crossing 400 ms almost always means the routing layer is steering some clients to a distant node, which is a placement problem covered in Choosing STUN Server Regions for Latency rather than a reachability one. On mobile paths the latency matters twice over: NAT bindings can refresh in under 30 s, so a reflexive candidate discovered late may describe a mapping that no longer exists by the time the remote peer checks it.
One more split is worth carrying in the data from day one: address family. A dual-stack client gathers an IPv6 host candidate that frequently needs no reflexive lookup at all, so on IPv6-heavy networks a portion of sessions legitimately produce a usable path with a thinner IPv4 reflexive record. If you collapse both families into one yield number, an IPv6 rollout at a large carrier reads as a slow multi-week degradation of your STUN node. Recording the family per candidate costs one field and makes that shift self-explanatory instead of a week of investigation.
Minimal Runnable Implementation
Two instruments, deployed together. The first is a per-session record emitted by every client; it is the SLO. The second is a synthetic prober that answers the question the SLO cannot β is the node down, or did the population change? β within seconds instead of within a traffic window.
// Attach BEFORE createOffer() so t0 is honest. One record per PeerConnection.
const probe = { t0: performance.now(), srflx: [], errors: [], host: 0 };
pc.addEventListener('icecandidate', (e) => {
if (!e.candidate) return; // null == end of gathering, not a candidate
const c = e.candidate;
if (c.type === 'host') probe.host++; // 0 host => gathering never really ran; exclude later
if (c.type === 'srflx') {
probe.srflx.push({
url: c.url || 'unknown', // Chrome only: WHICH iceServers entry answered
ms: Math.round(performance.now() - probe.t0),
family: (c.address || '').includes(':') ? 'ipv6' : 'ipv4'
});
}
});
// The signal most teams never wire up: the server did not answer at all.
pc.addEventListener('icecandidateerror', (e) => {
// 701 = no response from the STUN/TURN server (socket-level timeout)
// 300..699 = a real STUN error response came back, so the node IS alive and routable
probe.errors.push({ url: e.url, code: e.errorCode, text: e.errorText, port: e.port });
});
pc.addEventListener('icegatheringstatechange', () => {
if (pc.iceGatheringState !== 'complete') return;
navigator.sendBeacon('/metrics/stun', JSON.stringify({
region: EDGE_REGION, // stamped by your edge, never client-supplied
asn: EDGE_ASN, // separates "our node is down" from "this office blocks UDP"
relayOnly: pc.getConfiguration().iceTransportPolicy === 'relay', // denominator exclusion
hostCandidates: probe.host,
srflxCount: probe.srflx.length,
firstSrflxMs: probe.srflx.length ? probe.srflx[0].ms : null,
stunErrors: probe.errors // empty array on a clean gather
}));
});
Three details carry the whole design. RTCPeerConnectionIceErrorEvent.errorCode === 701 is the difference between unreachable and broken: 701 means nothing came back on the socket, while any 300β699 code means a STUN error response arrived, which proves the path and the process are both alive. candidate.url is Chrome-specific but invaluable when iceServers lists more than one entry β without it you cannot attribute a candidate to a node, and a self-hosted outage masked by a public fallback looks like perfect health. And the event-driven emit costs nothing: there is no need to poll getStats() at 1 s intervals for this, because gathering is a one-shot phase and every fact you need arrives as an event.
The prober is deliberately dumb and deliberately hosted somewhere your production VPC is not β a probe that originates inside the same security group validates a path no user ever takes.
# /etc/stun-probe.conf β synthetic Binding prober, one per external vantage point
[probe]
targets=stun-use1.example.net:3478,stun-euw1.example.net:3478
family=udp4 # probe UDP explicitly; a TCP connect to 3478 proves nothing
interval=30 # seconds between Binding Requests per target
timeout=1500 # ms β ~50x a healthy 30 ms RTT, so only true silence trips it
retries=1 # one retransmit, then the probe is a failure
verify_mapped=true # fail if MAPPED-ADDRESS is RFC 1918 (external-ip misconfigured)
[alert]
consecutive_failures=3 # 3 x 30 s = 90 s to page, short enough to beat most call volume
Keep the label set on the aggregated series small. Region, endpoint, address family, and a coarse network class are enough to answer every question the triage path below asks; adding raw ASN or user identifiers as labels multiplies series count without changing a single decision. Store the fine-grained fields on the raw beacon records instead, where you can query them after an alert fires. Sampling is safe too β reflexive yield is a population statistic, and 10% of sessions in a busy region still resolves a 1% shift long before a human reads the graph, while a low-traffic region needs every record it can get and should stay unsampled.
That backoff is why a silent region is expensive even when the call ultimately succeeds: the agent keeps retransmitting on a doubling RTO while the user waits, and the connection completes on whatever host or relay pair happened to work. It is also why the 1500 ms prober timeout is generous β a healthy node answers two orders of magnitude faster, so anything past that is silence rather than slowness.
Reproduction Steps & Debugging Log Patterns
- Deploy the prober against every regional endpoint and let it run for an hour to establish that all vantage points report a
MAPPED-ADDRESSmatching each nodeβs real public IP. - Ship the client instrumentation behind a sampling flag and confirm the reference population settles in the 98β99.5% band. If it does not, fix the measurement before trusting the alarm.
- Blackhole one region exactly as a bad security-group rule would:
iptables -I INPUT -p udp --dport 3478 -j DROPon that node. Do not stop the process β the whole point is that the process stays up. - Watch what does not happen: a TCP health check on 3478 still succeeds if you left the TCP listener enabled, systemd still reports the unit active, and the node stays in rotation. This is the silent failure, reproduced.
- Confirm the prober pages within 90 s and that per-region yield for that endpoint collapses to near zero in the next reporting window, while the other regions stay flat.
A client in the affected region logs this shape β note that gathering still completes, which is why iceGatheringState is useless as a health signal:
// t+2ms candidate host 192.168.1.24 typ host
// t+3ms candidate host fe80::1c2b typ host
// t+250ms (no srflx yet β TX2 retransmit)
// t+3990ms icecandidateerror url=stun:stun-euw1.example.net:3478 errorCode=701
// errorText="STUN binding request timed out" address=0.0.0.0 port=0
// iceGatheringState: complete // completes cleanly with zero srflx
// beacon: { region:'euw1', srflxCount:0, firstSrflxMs:null, hostCandidates:2 }
Compare that against a rate-limited or misconfigured node, which produces an error code in the 300β699 range instead of 701 β the node answered, it just refused. Cross-check any ambiguous case in a live dump; the candidate table and its timings are covered in Reading chrome://webrtc-internals Dumps.
Common Implementation Mistakes
- Health-checking the wrong protocol. A TCP connect, an ICMP ping, or an HTTP probe on a sidecar port all stay green while UDP 3478 is dropped upstream. The only valid liveness check for a STUN listener is a real Binding Request over UDP with a
MAPPED-ADDRESSassertion on the reply. - Probing from inside your own network. A prober in the same VPC or subnet skips the security group, the NAT gateway, and the internet path β precisely the three components that break. Put at least one vantage point on unrelated infrastructure per region.
- Alerting on an absolute candidate count. Reflexive candidates per minute follow your traffic curve, so an overnight trough looks like an outage and a busy hour hides one. Alert on the ratio, and suppress the rule below a minimum volume β roughly 50 sessions in the window β so low-traffic regions do not flap on a handful of samples.
- Blending UDP-blocked networks into the SLO. Offices that filter UDP egress yield zero reflexive candidates by policy, permanently. Aggregate them separately and treat their volume as demand for Forcing TURN over TCP 443 on Locked-Down Networks, not as a STUN incident.
- Not recording which server answered. With a self-hosted node listed first and a public resolver second, yield stays near 100% during a self-hosted outage because the fallback quietly covers it. Capture
candidate.urland compute yield per endpoint, or the redundancy you built will also hide the fault it was meant to survive.
FAQ
What success rate should I actually page on?
Page on the reference population only: consumer and mobile clients in a region, excluding relay-only and zero-host sessions. Below 95% sustained for 10 minutes is a real regression; below 90% is an outage. Keep the fleet-wide blended number on a dashboard as a trend line but never as a trigger β it moves whenever your user mix moves.
Should I trust the client metric or the synthetic prober?
Both, for different jobs. The prober is fast and unambiguous β 90 seconds to detect, and its verdict immediately tells you whether the node or the population changed β but it only tests the paths you thought to test. Client yield is slow and noisy but tests every real path, including the carrier that started filtering UDP last night. Fire the page from the prober, and use yield to size the blast radius and to catch the failures the prober cannot see. The same two-signal pattern applies to media SLOs, as in Alerting on Freeze and Packet-Loss SLOs.
Do the serverβs own metrics have any value here?
Yes, but as corroboration rather than detection β they distinguish βno packets arrivedβ from βpackets arrived and we dropped them on a quotaβ. Scrape them alongside the rest of your real-time fleet using the approach in Exporting SFU Metrics to Prometheus and Grafana, and remember that a STUN-only listener generates none of the allocation counters a relay does.
Related: return to STUN Server Deployment Strategies, and read Self-Hosting Coturn STUN vs Public STUN for the node you are monitoring and WebRTC over CGNAT for why mobile yield behaves differently from broadband.