All notes
4 min read

Why WebRTC calls fail for some users and not others

A diagnostic walk through ICE: what host, srflx and relay candidates mean, why symmetric NAT and CGNAT break direct connections, and how to read candidate pairs to find the actual failure.

AAAsghar AliFounder & Lead Engineer · Daniotech
The three ICE candidate types — host, server-reflexive via STUN, and relay via TURN — ordered from cheapest to costliest.
The three ICE candidate types — host, server-reflexive via STUN, and relay via TURN — ordered from cheapest to costliest.

The report always arrives in the same shape. "It works for us, but one client can't connect." Nobody can reproduce it. The logs look fine.

This is almost never a mystery. It is ICE, and ICE is legible if you know where to look.

Connections are negotiated, not opened

A WebRTC connection is not a socket you open. It is a negotiation. Each side gathers candidates — possible network paths to itself — trades them through your signalling channel, then tests every pairing until one works.

Three candidate types matter:

Type Discovered by Works when
host Reading local interfaces Both peers on the same network
srflx Asking a STUN server The NAT reuses one public mapping
relay Allocating on a TURN server Basically always, at bandwidth cost

The browser tries these roughly in that order, because that is also the order of increasing cost. A direct path is free. A relayed path is somebody's bandwidth.

Where it breaks

Symmetric NAT

Most home routers use a cone NAT: one internal socket gets one external mapping, and anyone who learns that mapping can reach it. STUN works beautifully here — the peer asks "what do I look like?", gets an answer, and shares it.

A symmetric NAT creates a different external mapping per destination. The address STUN reported describes the path to the STUN server and nobody else. The remote peer sends packets to it and they land nowhere.

This is common in corporate firewalls and some mobile carriers. There is no clever workaround. It requires a relay.

Carrier-grade NAT

Mobile networks and some ISPs place customers behind CGNAT, sharing one public address across many subscribers. You will see 100.64.0.0/10 on the interface — that range is reserved for exactly this. Two users behind the same CGNAT often cannot reach each other directly even though they appear to share a network.

UDP blocked entirely

Plenty of corporate networks permit only TCP on 80 and 443. WebRTC media strongly prefers UDP; when UDP is gone, every UDP candidate fails silently and you are left with TCP relay — which requires a TURN server listening on TCP, ideally TLS on 5349, which looks enough like ordinary HTTPS to pass.

If you have no turns: entry in your ICE configuration, these users cannot call.

Reading the failure

Open chrome://webrtc-internals during a failing call. It is not pretty but it is authoritative.

Find the RTCIceCandidatePair entries and look at state. You want one pair in succeeded. What you usually see in a broken call is every pair failed or stuck in in-progress.

Then look at what was gathered at all:

pc.addEventListener('icecandidate', ({ candidate }) => {
  if (!candidate) return console.log('gathering complete')
  // "host" | "srflx" | "prflx" | "relay"
  console.log(candidate.type, candidate.protocol, candidate.address)
})

pc.addEventListener('iceconnectionstatechange', () => {
  console.log('ICE:', pc.iceConnectionState)
})

Three diagnoses follow directly:

  • No relay candidate ever appears. Your TURN server is unreachable, the credentials are wrong, or they expired. The client will work on friendly networks and fail on hostile ones — which is exactly the bug report you got.
  • relay appears but pairs still fail. The relay range is closed on the firewall, or external-ip is wrong and the candidate advertises a private address.
  • Everything gathers, state reaches connected, then drops to failed after ~30 seconds. Usually a consent-freshness failure: the path came up but cannot sustain traffic. Look for an aggressive middlebox or an idle timeout.

The uncomfortable part

You cannot fix this by testing harder on your own network. Your network is, by definition, the one where it already works.

What actually helps:

  1. Ship TURN before you think you need it, including a turns: entry on 5349. The users who need it are precisely the ones who cannot tell you why it broke.
  2. Log the selected candidate pair type for every call, server-side. Once you know what fraction of your calls are relayed, TURN capacity becomes a planning question instead of an emergency.
  3. Test from a genuinely hostile network — a corporate guest wifi, a mobile hotspot with the phone on cellular. Not a second laptop on the same router.

Instrumenting the candidate pair takes an afternoon and turns this entire class of bug from "unreproducible" into a number on a dashboard.

const stats = await pc.getStats()
for (const report of stats.values()) {
  if (report.type === 'candidate-pair' && report.state === 'succeeded') {
    const local = stats.get(report.localCandidateId)
    const remote = stats.get(report.remoteCandidateId)
    // Send these two fields to your backend. They are the whole story.
    console.log('selected:', local?.candidateType, '->', remote?.candidateType)
  }
}

If local.candidateType is relay for a meaningful share of your traffic, that is not a bug — that is the internet working as designed, and your TURN server earning its keep.


Related: setting up coturn for production, which covers the relay configuration this post assumes you have.

  • #WebRTC
  • #ICE
  • #NAT traversal
  • #Debugging

Working on this?

We build and fix real-time systems.

If your calls fail for a subset of users and you can't reproduce it, that's diagnosable. Take a live demo call and we'll walk the architecture with you.