WebRTC over CGNAT (Carrier-Grade NAT)
Carrier-grade NAT (CGNAT, RFC 6598) sits between millions of mobile and broadband subscribers and the public internet, sharing a small pool of public IPv4 addresses across thousands of customers. This guide is part of the ICE Candidate Gathering & Filtering guide, and it addresses one decision: how to keep WebRTC connections alive over CGNAT, where bindings expire fast, ports run out, and direct paths frequently never form.
Context & Trade-offs
CGNAT magnifies every NAT problem. Two properties dominate. First, binding lifetime: to conserve table space across thousands of subscribers, carriers age out idle UDP mappings aggressively β often in under 30 seconds, sometimes as low as 20 s. A srflx candidate discovered at call setup can be dead before connectivity checks finish, and a connection that goes briefly idle can lose its mapping mid-call. Second, port exhaustion: with thousands of subscribers behind one public IP, the carrier may run a per-subscriber port budget. Under load, new mappings get refused, so additional candidate gathering or a fresh allocation simply fails.
Most CGNAT deployments are also symmetric, which means STUN srflx candidates rarely produce a usable direct path β the same failure mode covered in Traversing Symmetric NAT with TURN. The practical consequence: assume a relay will be needed, keep mappings warm with frequent keepalives, and design for fast re-establishment rather than fighting for a direct path.
The trade-offs are concrete. Sending consent/keepalive traffic every 5β15 s holds the binding open at the cost of a trickle of background bandwidth and battery on mobile. Routing through a TURN Server Configuration & Auth relay adds 20β40 ms of one-way latency but converts a near-certain failure into a reliable call. Skipping keepalives saves battery but invites a silent drop the moment the user stops talking.
It is worth separating the two mechanisms that keep a CGNAT call alive, because they fail differently. WebRTCβs built-in ICE consent freshness (RFC 7675) sends a STUN binding request on the nominated pair roughly every 5 s and tears the connection down if it gets no response for ~15 s β that protects the active media path. But consent only runs on the pair carrying media; a paused or muted call can still let the underlying UDP mapping age out faster than consent notices, especially when the OS suspends the radio. An application-level keepalive on a data channel forces actual packets through the mapping on a schedule you control, independent of whether audio/video is flowing. The cleanest design uses both: rely on consent freshness for liveness detection, and add a short-interval data-channel heartbeat to keep the NAT binding warm during silence. When the mapping is lost anyway β port exhaustion, a radio handoff of the kind described in Handling Wi-Fi to Cellular Network Handover, a genuinely long idle β triggering an ICE restart without dropping media re-gathers and re-nominates on the same session, which is far cheaper than a full renegotiation or a user-visible reconnect.
Why the aging timer is so short β port-budget arithmetic
The 20β30 s idle timer is not carrier malice, it is table arithmetic. A single public IPv4 address exposes about 64,000 usable ports, and an operator that oversubscribes at 500 subscribers per address can hand each subscriber only ~128 simultaneous mappings. Every mapping occupies a translation-table row that must be held in fast memory on the border device, so the cheapest way to raise the effective ratio is to reclaim idle rows quickly. Shortening the UDP idle timer from 120 s to 25 s multiplies the number of subscribers a given table can serve, at the cost of breaking exactly the long-lived, occasionally-silent flows that real-time media depends on.
That arithmetic is also why the bundlePolicy and rtcpMuxPolicy settings in the configuration below are load-bearing rather than cosmetic. A peer connection without BUNDLE opens a separate transport for audio and for video, and without RTCP multiplexing each transport opens a second socket for RTCP β four mappings per call instead of one. On a subscriber with a 128-port budget who also has a phone syncing mail, that difference is the gap between a call that connects and a fresh allocation that is refused. For the same reason, leave iceCandidatePoolSize at 0 on mobile builds: pre-warming sockets speculatively burns ports from the same budget before the user has even dialled.
The one genuine escape from the whole problem is address family. Most large mobile networks now run IPv6-only radio bearers with 464XLAT, so the handset holds a real, globally routable IPv6 address alongside its synthesised 100.64.0.0/10 IPv4 view. If both peers gather IPv6 host candidates, the CGNAT is not in the path at all β no translation row, no aging timer, no port budget β and ICEβs address-family precedence rules will usually nominate that pair first. Getting the candidate ordering and the fallback right is covered in IPv6 Dual-Stack ICE Handling; on a dual-stack carrier it is the single highest-leverage change available, because it removes the failure mode instead of mitigating it.
Minimal Runnable Implementation
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: [
'turn:turn.example.com:3478?transport=udp',
'turns:turn.example.com:5349?transport=tcp' // survives UDP-hostile carriers
],
username: 'time-limited-user',
credential: 'base64-hmac-token'
}
],
iceTransportPolicy: 'all', // try direct first; ICE falls back to relay on CGNAT
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require'
});
// WebRTC sends STUN consent checks every ~5 s automatically, but an idle
// data channel keeps the binding hot below the carrier's <30 s aging timer.
function startKeepalive(pc) {
const dc = pc.createDataChannel('keepalive', { negotiated: true, id: 0 });
dc.onopen = () => {
const timer = setInterval(() => {
// 1-byte heartbeat well under the 30 s binding lifetime
if (dc.readyState === 'open') dc.send('\0');
else clearInterval(timer);
}, 10000); // 10 s interval: safe margin under a 20β30 s CGNAT timeout
};
}
// On a dropped binding, re-gather rather than tearing the call down
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'disconnected') {
pc.restartIce(); // refreshes mappings; cap retries at 3
}
};
Set the keepalive interval to roughly half the observed binding lifetime β 10 s is a safe default against a 20β30 s timeout. Always offer a TLS TURN endpoint on 5349, and where carriers throttle or block raw UDP outright, the technique in Forcing TURN over TCP 443 on Locked-Down Networks is the last reliable escape hatch.
The relay allocation has its own clock
Keeping the carrier mapping warm is only half the job, because the TURN allocation sitting behind it expires on a separate schedule. Under RFC 8656 an allocation defaults to a 600 s lifetime that the client refreshes, per-peer permissions expire after 300 s, and channel bindings last 600 s with the client expected to re-bind at roughly half that. Browsers handle those refreshes internally, which is why they normally go unnoticed β until CGNAT breaks the assumption underneath them. A Refresh request is only accepted on the same 5-tuple that created the allocation, so if the carrier drops the mapping and the clientβs next packet leaves from a newly assigned public port, the server sees an unknown 5-tuple and answers 437 Allocation Mismatch. The old allocation is now orphaned: it holds a relay port on your TURN server for up to ten more minutes while the client must request a brand-new one against a port budget that may already be exhausted. This is the mechanism behind the βrestartIce did nothingβ reports β the restart is fine, the allocation it needs is not available.
Two settings keep that from compounding. On the client, a keepalive interval at half the observed binding lifetime means the refresh never has to survive a mapping change. On the server, cap what a single subscriber can orphan.
# coturn: predictable allocation accounting under CGNAT churn
listening-port=3478 # STUN/TURN over UDP and TCP
tls-listening-port=5349 # TURNS; the fallback for UDP-hostile carriers
min-port=49152 # relay port range start
max-port=65535 # relay port range end
stale-nonce=600 # nonce validity; forces periodic re-auth
channel-lifetime=600 # RFC 8656 default, client re-binds near 300 s
permission-lifetime=300 # per-peer permission window
user-quota=6 # concurrent allocations per credential
total-quota=1200 # server-wide ceiling, sized to relay port range
no-multicast-peers # refuse relaying to multicast destinations
Set user-quota above the number of transports a single call needs β with max-bundle that is one, so 6 leaves headroom for a reconnect storm without letting one looping client consume the range. Watch the ratio of Allocate to Refresh requests in the server logs: on a healthy fleet Refresh should dominate, and a rising Allocate share is a direct measure of how often carrier mappings are dying under your users.
Reproduction Steps & Debugging Log Patterns
- Place a client on a mobile carrier known to use CGNAT and establish a call, then stop all media/data for 35 s.
- Poll
pc.getStats()at 1 s intervals and watch the nominatedcandidate-pairforconsentRequestsSentrising andresponsesReceivedstalling. - Observe
iceConnectionStateflip todisconnectedshortly after the binding ages out, then watch whetherrestartIce()recovers it. - Re-run with a 10 s keepalive enabled and confirm the binding survives the idle window.
Expected log on binding expiry without keepalive:
// t+0s candidate-pair (relay/srflx) state: succeeded nominated: true
// t+28s consentRequestsSent: 6 responsesReceived: 4 <- mapping aging out
// t+31s iceConnectionState: disconnected
// t+31s restartIce() -> iceConnectionState: checking -> connected
If restartIce() cannot recover and you see no new relay candidate, suspect port exhaustion on the carrier β the allocation request is being refused. Fall back to the already-established TLS relay rather than gathering fresh candidates.
Failure mode: two subscribers behind the same carrier NAT
The nastiest CGNAT bug looks like success in the SDP. When both peers sit behind the same carrier device, both gather a srflx candidate on the same public address β 203.0.113.7 with two different ports β and ICE dutifully forms a pair out of them. That pair looks like the highest-priority non-relay route, so it gets checked first. Most carrier NATs do not hairpin: traffic addressed to their own external address from the inside is not looped back, and it is dropped without an ICMP response. The connectivity check therefore does not fail fast, it goes silent. Chrome retransmits a binding request with an exponential backoff seeded around 500 ms, so the pair sits in in-progress for roughly 2β4 s before the relay pair is nominated instead β which the user experiences as several seconds of black video on an otherwise good connection.
Diagnosing it takes one comparison: open a dump as described in Reading chrome://webrtc-internals Dumps and check whether the remote candidateβs IP is identical to your own srflx IP. If it is, you are looking at a hairpin attempt, not a real path. Note that Chromeβs mDNS .local host candidates are a red herring here β the two subscribers are not on a shared LAN, so host candidates were never going to help. The fix is not to filter the srflx pair (it does work on carriers that do hairpin, and it is genuinely the better path when it does); it is to make sure the relay candidate is available early, by trickling candidates as they arrive rather than waiting for gathering to complete, so nomination has a working alternative the moment the hairpin check stalls.
Background suspension kills a JavaScript keepalive
The heartbeat in the implementation above runs on setInterval, and mobile browsers deliberately stop honouring that in the background. Safari on iOS and WKWebView suspend page timers within seconds of the app leaving the foreground, and Chrome on Android throttles background timers to roughly one execution per minute, tightening further after about five minutes. A 10 s heartbeat quietly becomes a 60 s heartbeat against a 25 s timer, so the connection dies precisely in the scenario the keepalive was written for. ICE consent freshness keeps running because it lives in the native networking thread rather than the JavaScript event loop, but consent only detects the loss β it never prevents it. Treat foreground/background transitions as an explicit connection event.
// Timers are throttled or suspended in the background; re-check on resume
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
// Consent freshness may already have moved us out of 'connected'
if (pc.iceConnectionState !== 'connected' &&
pc.iceConnectionState !== 'completed') {
pc.restartIce(); // re-gather immediately rather than waiting ~15 s for 'failed'
}
});
Common Implementation Mistakes
- No keepalive on idle connections. A muted, paused call goes silent, the CGNAT binding ages out in under 30 s, and the next packet is dropped β the user perceives a random disconnect.
- Keepalive interval too long. A 30 s interval against a 25 s timeout still loses the binding; set it to roughly half the lifetime.
- STUN-only configuration. CGNAT is usually symmetric; without a relay, direct paths fail and there is nothing to keep alive.
- Tearing down on the first
disconnected. The difference between disconnected vs failed ICE states matters here:disconnectedis usually transient, andrestartIce()recovers most CGNAT mapping losses without a full renegotiation. - Ignoring port exhaustion. Repeatedly re-gathering under a carrier port budget makes things worse; reuse the existing relay allocation instead.
- Leaving BUNDLE and RTCP-mux optional. Negotiating separate transports quadruples the mappings one call consumes from a ~128-port subscriber budget, and the extra allocations are the first to be refused.
- Pre-warming with
iceCandidatePoolSize. Speculative socket allocation trades a small setup-time win for ports you may need later; on a carrier budget the trade is the wrong way round.
FAQ
How short can a CGNAT binding lifetime really be?
Commonly 30β120 s for UDP, but aggressive carriers age idle mappings in under 30 s β some near 20 s. Always assume the worst and keep mappings warm.
Does a keepalive drain mobile battery noticeably?
A 1-byte heartbeat every 10 s is negligible compared to active media. The radio is already awake during a call; the cost only matters for long-idle background connections, where you can stretch the interval slightly.
Why does my call work on Wi-Fi but fail on cellular?
Home Wi-Fi is usually a single cone NAT with generous timeouts; cellular is symmetric CGNAT with short binding lifetimes. Provision TURN and keepalives specifically for the cellular path.
Can I detect from the browser that a client is behind CGNAT?
Not definitively, but two signals get you close. If a gathered srflx candidate carries a relatedAddress in the 100.64.0.0/10 shared address space defined by RFC 6598, the client is behind a carrier translator by definition. Failing that, gather against two STUN servers in different regions and compare the reported reflexive ports: a symmetric translator hands out a different port per destination, so mismatched ports on the same local socket are a strong indication. Neither test is worth blocking call setup on β measure it as telemetry, and let the result drive whether you provision that client relay-first.
Should I just force iceTransportPolicy: 'relay' on cellular?
It is a defensible trade rather than a default. Relay-only skips a round of doomed host and srflx checks, which typically saves 2β4 s of setup time on a carrier that neither hairpins nor supports a direct path, and it makes connection outcomes uniform enough to alert on. The costs are the 20β40 ms of one-way relay latency on every call including the ones that would have connected directly, plus TURN egress bandwidth for 100% of your cellular traffic. The middle path most teams settle on is adaptive: gather normally on the first attempt, record whether the nominated pair was a relay, and pin subsequent calls from that network to relay-first for the session.
Does a shorter keepalive keep the mobile radio awake and cost battery?
Somewhat, and the shape of the cost is worth knowing. LTE and 5G radios drop from a connected state to an idle or discontinuous-reception state after a network-configured inactivity window, commonly around 10 s, and re-establishing the bearer costs both latency and energy. A 10 s heartbeat therefore tends to hold the radio in its connected state rather than repeatedly paying the promotion cost β which is usually the cheaper outcome during a call, and the more expensive one for a connection idling in the background for minutes. That asymmetry, not the byte count, is the reason to stretch the interval on backgrounded sessions and keep it tight on active ones.
Related: return to ICE Candidate Gathering & Filtering, and see Traversing Symmetric NAT with TURN and ICE Candidate Trickle vs Bulk Gathering.