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.

CGNAT topology and its effect on ICE candidate types Three subscribers in RFC 6598 shared address space traverse a carrier-grade NAT that owns one public address, applies a per-subscriber port budget and ages idle UDP mappings in 20 to 30 seconds. The STUN reflexive path is usually unusable because the NAT is symmetric, while the TURN relay path reaches the remote peer. One public IPv4, thousands of subscribers, 20–30 s mappings Subscriber A (mobile) 100.64.3.11:51820 Subscriber B (mobile) 100.64.7.44:49330 Subscriber C (fixed) 100.72.1.9:60112 Carrier-Grade NAT Public pool 203.0.113.7 Per-subscriber port budget UDP mapping idle 20–30 s Symmetric: new port per destination Table pressure β†’ fresh allocations refused STUN 3478 β†’ srflx candidate rarely usable under symmetric CGNAT TURN 3478 / TLS 5349 β†’ relay reliable; adds 20–40 ms one-way Remote peer Plan for the relay path: gather early, keep the mapping warm, re-establish fast.
Subscribers share one carrier address; symmetric mapping kills the srflx path and leaves the relay.

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.

Idle-call timeline: CGNAT mapping lifetime with and without a heartbeat Over a 40 second idle window, ICE consent checks fire every 5 seconds. Without an application keepalive the carrier mapping ages out around 25 seconds and ICE reports disconnected, recovered by restartIce at 31 seconds. With a 10 second data-channel heartbeat the mapping stays alive for the whole window. 40 s of silence: what survives the carrier's aging timer ICE consent (~5 s) Idle, no keepalive mapping dies mid-call binding alive binding lost t+25 s aged out restartIce() at t+31 s 10 s heartbeat mapping stays warm binding alive through the whole idle window 0 s 5 10 15 20 25 30 35 40 s
Consent freshness detects the loss; only the 10 s heartbeat prevents it.

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

  1. Place a client on a mobile carrier known to use CGNAT and establish a call, then stop all media/data for 35 s.
  2. Poll pc.getStats() at 1 s intervals and watch the nominated candidate-pair for consentRequestsSent rising and responsesReceived stalling.
  3. Observe iceConnectionState flip to disconnected shortly after the binding ages out, then watch whether restartIce() recovers it.
  4. 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.

ICE state transitions after a CGNAT mapping loss From connected, stalled consent responses move the connection to disconnected. Calling restartIce moves it to checking, which either re-nominates a pair and returns to connected, or reaches failed when the carrier refuses a new allocation. Disconnected also reaches failed after roughly fifteen seconds without a response. ICE states after a lost mapping pair re-nominated connected media flowing consent lost disconnected often transient restartIce() checking re-gathering ~15 s with no response no new relay candidate offered failed allocation refused Cap restarts at 3. On failed, reuse the standing TLS relay allocation instead of gathering against a port budget.
disconnected is recoverable; failed on CGNAT usually means the carrier refused a new allocation.

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

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.