The support ticket arrives at 10pm. "Video call won't connect on my phone." You ask the user to try the same call on their home Wi-Fi. It works. They open it again on cellular data. It fails. Same app, same browser, same account.
This is the most common WebRTC support ticket in the world, and the cause is almost always the same: the user is now behind a carrier-grade NAT (CGNAT) and your app's peer-to-peer connection can't traverse it. Your dev environment never sees this because every developer's home connection gets a real public IP. Once a real user pulls the SIM card and walks out the door, the math changes.
What Mobile Networks Actually Do to Your Packets
Mobile carriers ran out of IPv4 addresses years ago. The fix the industry settled on is carrier-grade NAT: a single public IP shared among hundreds or thousands of subscribers, with the carrier's NAT box rewriting source ports on the way out and tracking state for return traffic.
This is fine for HTTP. Your phone makes an outbound TCP connection to a server, the carrier NAT remembers the mapping, return packets find their way back. That's how the rest of the internet works.
WebRTC peer-to-peer breaks here for two reasons:
- Symmetric NAT behavior. Many carrier NATs use a different external port for each destination. The address one peer learns about you (via STUN) is useless to a different peer because the NAT will reject incoming packets from the second peer's source.
- UDP packet drops. Some carriers prioritize TCP traffic and drop unsolicited UDP packets after short idle windows. Your media stream stutters or stops entirely.
Add to this the fact that some carriers (and corporate firewalls in coffee shops, hotels, hospitals) block outbound UDP entirely as a "security" policy, and you have a meaningful percentage of users for whom direct peer-to-peer simply cannot work.
The Symptom: ICE Failed, Connection Failed
In your application, this presents as one of three failure modes:
- The call rings forever, never establishing.
- One-way audio or video (one peer can hear, the other can't).
- The call connects, then drops 30 seconds later when a NAT timeout expires.
If you check chrome://webrtc-internals on the failing peer, you'll see ICE
candidates collected (host and server-reflexive) but the connectivity check failing. The browser
tried to send packets to the other peer's announced address; the other peer's NAT silently
dropped them.
The Fix: TURN with Multiple Transports
The straightforward fix, the one every production WebRTC application eventually adopts, is to
list a TURN server with multiple transport options in your iceServers config. ICE will
try direct first, fall back to relayed, and pick whichever path actually carries packets.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.expressturn.com:3478' },
{
urls: [
'turn:relay1.expressturn.com:3478?transport=udp',
'turn:relay1.expressturn.com:3478?transport=tcp',
'turns:relay1.expressturn.com:443?transport=tcp'
],
username: 'YOUR_TURN_USERNAME',
credential: 'YOUR_TURN_PASSWORD'
}
],
iceTransportPolicy: 'all'
});
The three TURN URLs each rescue a different kind of broken network:
- UDP on 3478 handles the simplest CGNAT case where direct UDP doesn't work peer-to-peer but does work to a fixed relay.
- TCP on 3478 handles networks where UDP is dropped or rate-limited.
- TLS on 443 handles the most restrictive networks: hotel Wi-Fi blocking non-standard ports, hospital firewalls, locked-down corporate guest networks. To the firewall this looks identical to ordinary HTTPS, so it gets through everywhere.
You don't have to pick. List all three. ICE picks the first one that establishes a path.
Testing on a Real Mobile Network
Wi-Fi testing on the same LAN as your dev machine will never reproduce the bug. Two ways to exercise the failure mode:
- Phone on cellular only. Turn off Wi-Fi on a real phone, browse to your app, join a call. If your TURN config is wrong, this fails.
- Force-relay in the browser. Set
iceTransportPolicy: 'relay'during testing to force every connection through TURN. If calls work in this mode, your TURN credentials are good. If they don't, fix that before chasing other bugs.
Open chrome://webrtc-internals while testing. The "selected candidate pair" should
have type relay when force-relay is on, and your local user-agent string should show
the mobile profile.
Bandwidth Reality Check
Adding TURN means your relay carries the media for those mobile users. Some math:
- A typical 720p video call on cellular (1.5 Mbps): ~675 MB per relayed-hour combined both directions.
- An audio-only Opus call: ~30 MB per relayed-hour.
- If 25% of your calls go through TURN, multiply by your call volume.
For most B2B SaaS apps with hundreds of daily calls, this comes out to tens of GB per month at worst. The free tier on most managed TURN services covers it. Once you cross 1 TB of relayed traffic per month, predictable flat-pricing TURN providers (ExpressTURN at $9 for 5 TB) start beating per-GB providers (Twilio Network Traversal at roughly $0.40 per GB) by an order of magnitude.
The Two-Line Summary
If your app works on Wi-Fi and breaks on cellular, you need TURN. Configure it with UDP, TCP, and TLS-443 transports, and your "doesn't work on my phone" tickets disappear.
Need a free TURN endpoint to test with on your next deploy? Grab credentials at ExpressTURN, 1 TB/month free, no credit card.