ICE & NAT Traversal
Two browsers cannot simply open a socket to each other. Each one usually sits behind a router that performs Network Address Translation (NAT), and the device's real address is private and unroutable from the public internet. ICE (Interactive Connectivity Establishment) is the procedure that finds a path through those routers. It collects every candidate address a peer might be reachable at, exchanges them over the signaling channel, then tests each pairing until one carries traffic.
This page covers what NAT is, how its behavior varies, how a STUN binding request works at the byte level, how ICE gathers candidates and computes their priority, how connectivity checks and nomination select a path, and the practical limit of a STUN-only setup like this one.
What NAT is and why it hides peers
A home or office network has one public IP address. Every device behind the router shares it. NAT is the function that lets many private addresses (192.168.x.x, 10.x.x.x, 172.16.x.x) share that single public address.
When a device sends a packet out, the router rewrites the source from the private ip:port to the public ip:port and records the mapping in a table. Reply packets that match an entry get rewritten back to the private address and delivered. The mapping exists only because outbound traffic created it.
That last point is the obstacle. The router holds no mapping for unsolicited inbound packets, so it drops them. Two peers each behind their own NAT hit the same wall in both directions: neither side knows the other's public ip:port, and neither router will admit a packet it did not expect. A peer cannot reach you at your private address (that address is meaningless outside your LAN) and it cannot reach you at your public address either, because nothing has opened a hole for it.
ICE works around this with two mechanisms. First, STUN tells a peer its own public ip:port. Second, hole punching: both peers send to each other's public address at roughly the same time, so each side's outbound packet creates the inbound mapping the other side needs. The first packets may be dropped; once both mappings exist, traffic flows.
Why a private address alone is useless
A host candidate carries an address like 192.168.1.42. That number is valid only inside the LAN that assigned it. Millions of networks reuse the same 192.168.1.x range. A peer on a different network that receives 192.168.1.42 as a destination either has no route to it or routes to its own local device of that name. The private address connects two peers only when they share the same network: same Wi-Fi, same office switch. Crossing the public internet requires the public mapping, and only STUN (or TURN) reveals that.
NAT behavior types
NAT is not one behavior. RFC 4787 splits it into two independent properties: how the router allocates the mapping (the public port), and how it filters inbound packets. The combination decides whether a direct path is possible. The classic four-name taxonomy (full-cone, restricted-cone, port-restricted, symmetric) is a shorthand for common combinations.
Mapping behavior
Endpoint-independent mapping. The router reuses one public ip:port for a given internal ip:port, regardless of destination. Send to a STUN server and to a peer from the same socket, and both see the same public port. This is the property that makes traversal possible: the port STUN reports is the port the peer can use.
Endpoint-dependent mapping. The router allocates a new public port per destination ip (or per destination ip:port). The port STUN reports applies only to the STUN server. The peer is a different destination, so it gets a different, unpredictable port. This is symmetric NAT, and it defeats STUN.
Filtering behavior
Endpoint-independent filtering. The router accepts inbound packets to an open mapping from any source. Permissive.
Address-dependent filtering. The router accepts inbound packets only from an IP the device has already sent to.
Address-and-port-dependent filtering. The router accepts inbound packets only from the exact ip:port the device has sent to. Strictest of the filters, but still traversable when mapping is endpoint-independent, because hole punching sends to the peer first and opens the filter.
The four common types
Full-cone NAT. Endpoint-independent mapping, endpoint-independent filtering. One public port per internal socket, and anyone may use it once it is open. The most permissive type. Traversal outcome: works easily; even an unsolicited peer can connect once the mapping exists.
Restricted-cone NAT. Endpoint-independent mapping, address-dependent filtering. One stable public port, but the device must have sent a packet to the peer's IP before that peer's packets are admitted. Traversal outcome: works; hole punching sends outbound to the peer's IP, satisfying the filter.
Port-restricted-cone NAT. Endpoint-independent mapping, address-and-port-dependent filtering. Stable public port; inbound admitted only from the exact ip:port already contacted. Traversal outcome: works; both peers send to each other's precise ip:port, each opening the other's filter.
Symmetric NAT. Endpoint-dependent mapping, address-and-port-dependent filtering. A fresh public port per destination, and strict filtering on top. Traversal outcome: usually fails with STUN. The srflx address learned from the STUN server uses the port for the STUN destination; the peer is a different destination and would see a different port that nobody can predict. Two symmetric NATs, or a symmetric NAT facing a port-restricted one, cannot hole-punch.
| NAT type | Mapping | Filtering | Traversable with STUN? |
|---|---|---|---|
| Full-cone | Endpoint-independent | Endpoint-independent | Yes, easiest |
| Restricted-cone | Endpoint-independent | Address-dependent | Yes |
| Port-restricted cone | Endpoint-independent | Address-and-port-dependent | Yes |
| Symmetric | Endpoint-dependent | Address-and-port-dependent | Usually no |
The decisive column is mapping. The three cone types share endpoint-independent mapping, so the STUN-reported port is reusable and hole punching works. Symmetric NAT's endpoint-dependent mapping makes the port unpredictable, and no amount of STUN fixes that. Solving symmetric NAT requires a TURN relay, which this project omits.
Carrier-grade NAT
Mobile networks and some ISPs add a second NAT layer in their own infrastructure (CGNAT). A device can then sit behind two NATs at once. CGNAT often behaves symmetrically and may block inbound UDP entirely. Treat it as the symmetric case for traversal purposes: STUN alone frequently fails.
What ICE does
The ICE agent inside RTCPeerConnection runs three phases:
- Gather candidates. Collect every address the peer might be reachable at: local interfaces, public mappings, and (if configured) relays.
- Exchange candidates. Send them to the remote peer over signaling. ICE does not transport candidates itself; that is the job of the signaling channel.
- Check connectivity. Pair local and remote candidates, send STUN probes across each pair, and keep the pair that works.
You configure the agent with the servers it may use when constructing the connection. Each entry is an RTCIceServer.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' },
// A TURN entry would look like this (deliberately absent here):
// { urls: 'turn:turn.example.com:3478', username: 'u', credential: 'p' },
],
iceTransportPolicy: 'all', // 'relay' would force TURN; 'all' allows host/srflx/relay
});
This project ships only the two STUN entries. With no TURN server in the list, the agent can never gather relay candidates, and a connection that needs one fails. That is intentional and explained in the recap.
STUN: learning your public address
STUN (Session Traversal Utilities for NAT) is a small request/response protocol defined in RFC 8489, which obsoletes the older RFC 5389 still cited by many stacks. Its job in WebRTC: tell a peer the public ip:port its NAT assigned.
The binding request
The agent sends a STUN binding request as a UDP datagram to the STUN server. A STUN message is a 20-byte header followed by zero or more attributes.
| Bytes | Field | Meaning |
|---|---|---|
| 0-1 | Message type | 0x0001 = binding request |
| 2-3 | Message length | Byte length of the attributes that follow |
| 4-7 | Magic cookie | Fixed 0x2112A442, identifies STUN and seeds the XOR |
| 8-19 | Transaction ID | 96-bit random value, ties the response to this request |
A plain binding request from a browser carries little beyond that header. The point is the source address the request arrives with.
What the server sees and returns
The request leaves the device with a private source ip:port. The NAT rewrites it to the public ip:port on the way out. The STUN server never sees the private address: it sees only what the NAT wrote. The server copies that observed source into a XOR-MAPPED-ADDRESS attribute and sends a binding response (0x0101) back, echoing the same transaction ID.
The address is XORed with the magic cookie (and, for IPv6, part of the transaction ID) before transmission. This is not encryption; it stops naive NATs or middleboxes that rewrite any raw IP they find in a packet body from corrupting the value. The client XORs it back to recover the real number.
What the peer learns
The device now knows the public ip:port its NAT assigned for traffic to that STUN server. On endpoint-independent (cone) mapping, the NAT reuses that same port for other destinations, so the peer can reach the device at it. This becomes a server-reflexive candidate. On endpoint-dependent (symmetric) mapping, the reported port applies only to the STUN server, so the candidate is useless to the peer.
STUN exchanges no media and inspects no content. It reports an address and nothing more. The boundary between STUN and TURN is covered in protocols.
Candidate types
A candidate is one transport address (ip, port, transport) plus a type and metadata. ICE gathers three kinds, and discovers a fourth during checks.
| Type | Source | Address it represents | When it works |
|---|---|---|---|
| host | Local network interface | Private LAN address (Wi-Fi, Ethernet) | Same network, or where the private address is directly reachable |
| srflx (server-reflexive) | STUN server | Public ip:port the NAT mapped for you |
Most home and office NATs |
| prflx (peer-reflexive) | A connectivity check | Public ip:port discovered from an incoming check |
Discovered mid-handshake; not gathered up front |
| relay | TURN server | An address on a relay that forwards your traffic | Symmetric NAT, strict firewalls: omitted in this project |
Host candidates come straight from the OS network interfaces. They cost nothing and connect instantly when two peers share a LAN. A machine with Wi-Fi and Ethernet yields a host candidate per interface.
Server-reflexive candidates are the public face of a host candidate, learned from STUN as described above. This is what lets two peers on separate networks find each other.
Peer-reflexive candidates are not gathered ahead of time. They appear during connectivity checks: if an incoming check arrives from an ip:port neither side advertised (common when a NAT maps a port the agent did not predict), ICE records it as a prflx candidate and may use it. It is a discovery, not a gathered route.
Relay candidates route traffic through a TURN server that forwards packets between peers. A relay always works because both peers only send outbound to the relay, but every byte then transits a third-party server. This project ships STUN only and includes no TURN relay. That is deliberate: gameplay traffic must stay peer-to-peer over RTCDataChannel, never crossing a server. The cost is that symmetric-NAT users cannot connect. See the recap.
Gathering candidates
Gathering starts when you call setLocalDescription(). The agent enumerates local interfaces, fires STUN binding requests, and emits each candidate through onicecandidate as it resolves. A null candidate marks the end of gathering.
pc.onicecandidate = ({ candidate }) => {
if (candidate) {
signaling.send({ candidate }); // trickle: send each as it arrives
} else {
// candidate === null → end-of-candidates; gathering complete
}
};
pc.onicegatheringstatechange = () => {
console.log(pc.iceGatheringState); // 'new' → 'gathering' → 'complete'
};
Reading an a=candidate line
Each candidate appears in the SDP as an a=candidate attribute. The fields are positional.
a=candidate:842163049 1 udp 1677729535 203.0.113.7 54321 typ srflx raddr 192.168.1.42 rport 49872 generation 0
└────┬───┘ │ └┬┘ └────┬────┘ └────┬────┘ └─┬─┘ └─┬─┘ └──────┬─────┘ └──┬─┘
foundation │ proto priority ip port type related addr related port
component
- foundation: an ID shared by candidates of the same type, base, and server. ICE freezes and unfreezes checks by foundation.
- component:
1for RTP/data,2for RTCP. A data channel uses component1. - proto:
udportcp. UDP is preferred for media and data. - priority: the 32-bit value below; higher checks first.
- ip / port: the candidate's transport address.
- typ:
host,srflx,prflx, orrelay. - raddr / rport: the related address. For a srflx candidate this is the host candidate (the private
ip:port) it was derived from. Present for srflx and relay; absent for host.
A host candidate from the same machine looks shorter (no raddr/rport):
a=candidate:998123456 1 udp 2122260223 192.168.1.42 49872 typ host generation 0
mDNS-obscured host candidates
Browsers no longer expose a raw private IP in host candidates by default. Doing so leaks the LAN topology to any page that opens an RTCPeerConnection. Instead the browser invents a random .local hostname, registers it over multicast DNS, and puts that in the candidate:
a=candidate:1 1 udp 2122260223 9b36eaac-bab2-4f8e-9d0c-1f2a3b4c5d6e.local 49872 typ host generation 0
The remote peer resolves the .local name over mDNS on its own network only if the two share a LAN; otherwise the host candidate is unusable and ICE falls back to srflx. This is privacy behavior, not a bug: host candidates connecting two strangers on the public internet was never possible anyway, so hiding the private IP costs nothing for cross-network play.
TCP candidates
Most candidates are UDP, which suits low-latency media and data. The agent can also gather TCP host candidates (tcp in the proto field, with tcptype active/passive/so). They matter when UDP is blocked by a firewall: a TCP path is slower but may be the only one that crosses. This project relies on UDP; TCP candidates appear in traces but are a last resort.
The candidate-priority formula
ICE orders checks by priority so the best path is tried first. RFC 8445 defines:
priority = (2^24) * type preference
+ (2^8) * local preference
+ (2^0) * (256 - component ID)
- type preference (0 to 126) ranks the candidate kind. Defaults: host = 126, prflx = 110, srflx = 100, relay = 0. Host is cheapest and lowest-latency, so it ranks highest; relay routes through a server, so it ranks lowest.
- local preference (0 to 65535) breaks ties between candidates of the same type, for example preferring Ethernet over Wi-Fi, or IPv6 over IPv4.
- component ID subtracts a small amount so component 1 (data/RTP) edges out component 2 (RTCP).
The result: host pairs are checked before srflx pairs, and srflx before relay: the agent finds the lowest-cost working path early.
One-shot gathering in this project
This project does not trickle. The manual-paste and URL handshakes need one self-contained SDP string, so src/salon/peer.js waits for gathering to finish before reading localDescription. Because a slow or unreachable STUN server can stall gathering, waitIce() resolves on iceGatheringState === 'complete' or after a timeout, whichever comes first:
export function waitIce(pc, timeoutMs = 4000) {
if (pc.iceGatheringState === 'complete') return Promise.resolve();
return new Promise(resolve => {
const timer = setTimeout(resolve, timeoutMs);
pc.addEventListener('icegatheringstatechange', () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(timer);
resolve();
}
});
});
}
The host and srflx candidates gathered within four seconds are almost always enough. Returning early trades a few late candidates for a faster handshake.
A worked gathering example
Take a laptop on home Wi-Fi behind a port-restricted-cone router, with the two STUN servers configured above. Calling setLocalDescription() produces, in rough order:
- A host candidate for the Wi-Fi interface: emitted almost instantly, an mDNS
.localname on UDP. If the laptop also has Ethernet or an IPv6 address, one host candidate per interface and family. - A srflx candidate once the binding response from
stun.l.google.comreturns the public203.0.113.7:54321. Itsraddr/rportpoint back at the Wi-Fi host candidate. - Possibly a second srflx candidate from the Cloudflare STUN server. Because the NAT is cone, both servers report the same public port, so the agent deduplicates and keeps one srflx per base.
- A
nullcandidate when gathering finishes, flippingiceGatheringStatetocomplete.
No relay candidate appears: there is no TURN server in the list. The peer therefore receives one host candidate (useful only on the same LAN) and one srflx candidate (the route that will actually carry traffic across the internet). On symmetric NAT the same sequence runs, but the srflx candidate's port is the STUN-only port, so the pair built from it fails its connectivity check.
Connectivity checks
Once both peers hold each other's candidates, ICE forms candidate pairs: every local candidate matched against every compatible remote candidate (same component, same IP family, same transport). It trusts no pair until proven.
The check list
The pairs go into an ordered check list, sorted by pair priority. RFC 8445 combines the two candidate priorities so both peers, computing independently, agree on the order:
pair priority = 2^32 * MIN(G, D)
+ 2 * MAX(G, D)
+ (G > D ? 1 : 0)
where G is the controlling agent's candidate priority and D the controlled agent's. The agent checks the highest-priority pair first.
The check itself
Each pair is tested with a STUN connectivity check: a binding request sent across the pair, expecting a binding response back, a STUN ping/pong over the candidate path rather than to a STUN server. A successful exchange proves packets flow both ways, which is exactly when hole punching has worked: each side's outbound check opens the inbound mapping and filter the other side needs.
Checks carry extra attributes that a plain binding request to a STUN server does not. USERNAME and MESSAGE-INTEGRITY, built from the ICE ufrag and password exchanged in the SDP, authenticate the check so a third party cannot inject a fake one. PRIORITY advertises the sender's view of the pair. The role attributes ICE-CONTROLLING and ICE-CONTROLLED declare which agent drives nomination and resolve the rare case where both think they are in charge.
Pair states
Every pair on the check list moves through five states:
- Frozen: not yet eligible to be checked. Pairs are frozen by foundation; once a pair with a given foundation succeeds or starts, related frozen pairs unfreeze. This avoids redundant checks on equivalent paths and staggers the work.
- Waiting: eligible and queued, not yet sent.
- In-Progress: a check has been sent; the agent awaits the response and may retransmit.
- Succeeded: the response arrived and validated. The pair is a valid path and becomes eligible for nomination.
- Failed: no response within retransmissions and timeout, or the response was an error. The pair is dead.
The agent paces checks (roughly one new check per timer tick, with retransmission on loss) rather than flooding the network, and unfreezes pairs as foundations resolve, so the list works through itself in priority order.
A triggered check
Connectivity checks are bidirectional. When an agent receives a check on a pair, it does not only reply; it schedules its own check back on that same pair if one is not already queued, a triggered check. This pairs the two directions quickly and is how an incoming check from an unexpected ip:port surfaces a peer-reflexive candidate.
Nomination
A succeeded pair is a usable path; nomination picks the one that carries traffic. The agent designated controlling (the offerer, in WebRTC) drives this; the controlled agent follows.
Regular nomination
The controlling agent lets checks run, collects succeeded pairs, then sends a second check on the chosen pair with a USE-CANDIDATE flag set. That flag tells the controlled agent: use this pair. Regular nomination waits for evidence before committing, so it picks a good pair and tolerates pairs that succeed slowly. Modern WebRTC uses regular nomination.
Aggressive nomination
The older alternative sets USE-CANDIDATE on the very first checks instead of waiting. The first pair to succeed is nominated immediately. It connects faster but can lock onto a worse path that simply happened to answer first, and RFC 8445 retired it in favor of regular nomination. It is mentioned here because older stacks and packet traces still show it.
Once a pair is nominated and both sides agree, media and data flow over it. ICE keeps the connection alive with periodic STUN keepalives on the selected pair. The connection's progress through these phases is reported by iceConnectionState, covered in the connection state machine.
ICE restart
A nominated pair can break: a phone moves from Wi-Fi to cellular, a NAT mapping times out, a route changes. The selected pair stops answering keepalives and ICE marks the connection disconnected, then failed if it does not recover.
An ICE restart recovers without tearing down the RTCPeerConnection. The agent generates a fresh ufrag and password, re-gathers candidates from scratch, and re-runs the whole check-list and nomination process while the old transport stays up until the new one is ready. Trigger it with restartIce(), or by creating an offer with the restart flag:
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
pc.restartIce(); // re-gather and re-check on the existing connection
// then renegotiate: createOffer() → setLocalDescription() → signal the new SDP
}
};
The restart still obeys the STUN-only constraint. If the network changed to one behind symmetric NAT, the restart re-gathers, fails the same way, and the connection cannot recover. A restart fixes a stale path, not an untraversable one.
Reading the selected pair
getStats() exposes which pair won, with live byte counts and round-trip time:
const stats = await pc.getStats();
for (const report of stats.values()) {
if (report.type === 'candidate-pair' && report.nominated && report.state === 'succeeded') {
const local = stats.get(report.localCandidateId);
const remote = stats.get(report.remoteCandidateId);
console.log('selected pair:', local.candidateType, '↔', remote.candidateType);
console.log('rtt:', report.currentRoundTripTime, 's');
console.log('bytes:', report.bytesSent, report.bytesReceived);
}
}
If local.candidateType and remote.candidateType are both host, the peers are on the same LAN. If one or both are srflx, hole punching across NATs succeeded. If either is relay, traffic is going through TURN, which cannot happen in this project, because no relay is configured.
The shape of each candidate object (type, protocol, address, port, priority) is documented in RTCIceCandidate.
Trickle ICE and end-of-candidates
Gathering every candidate before sending any of them adds latency: the agent may wait on a slow STUN server before the handshake can begin. Trickle ICE (RFC 8838) sends each candidate the instant it appears. The peer adds it with addIceCandidate() as it arrives, and connectivity checks start on the candidates already exchanged while gathering continues for the rest. Setup and first media happen sooner.
// Trickle: forward each candidate immediately.
pc.onicecandidate = ({ candidate }) => {
if (candidate) {
signaling.send({ candidate: candidate.toJSON() });
} else {
signaling.send({ candidate: null }); // explicit end-of-candidates
}
};
// Remote side feeds them in as they arrive.
signaling.onmessage = async ({ candidate }) => {
await pc.addIceCandidate(candidate ?? undefined); // null/undefined = end-of-candidates
};
End-of-candidates is the signal that no more candidates are coming. The local agent emits it as a null candidate from onicecandidate; the remote agent applies it by adding a null or empty candidate. It lets ICE stop waiting and conclude a pair has failed instead of hanging on the chance a better candidate is still in flight.
Trickle requires a live signaling channel that can deliver many small messages over time. This project uses one-shot SDP exchange (paste a code, share a URL), so it gathers fully and ships a single complete description instead of trickling. The trade is a slightly slower handshake for a simpler transport, and it is described in signaling.
Recap and troubleshooting
NAT hides each peer behind a router that drops unsolicited inbound packets. ICE gathers candidate addresses: host (local), srflx (public, via STUN), relay (via TURN). It exchanges them over signaling, then forms candidate pairs, sorts them by priority into a check list, runs STUN connectivity checks (pairs moving Frozen → Waiting → In-Progress → Succeeded/Failed), and nominates the best succeeded pair. The decisive factor for whether a direct path exists is NAT mapping behavior: endpoint-independent (cone) mapping is traversable with STUN; endpoint-dependent (symmetric) mapping is not.
When a STUN-only connection fails:
- Both peers behind symmetric NAT. Each NAT assigns a fresh public port per destination, so the srflx address learned from STUN does not match the port a peer would actually reach. No host or srflx pair passes its check, the check list exhausts, and
iceConnectionStatereachesfailed. This needs a TURN relay, which this project does not provide. - One symmetric NAT facing a port-restricted NAT. Same outcome: the symmetric side's port is unpredictable from the other side.
- Strict corporate or carrier-grade NAT or firewall. UDP may be blocked outright, or the NAT behaves symmetrically. Same outcome.
- No common candidate. If neither peer can offer a reachable host or srflx address, every pair fails. Check that STUN traffic (UDP) leaves the network and that a STUN server in the list is reachable.
- Gathering produced too few candidates. A blocked or unreachable STUN server yields host candidates only. Two peers on different networks then have nothing to pair. Inspect the
a=candidatelines in the SDP. If there are notyp srflxlines, STUN did not reach a server. - A candidate arrived before the remote description.
addIceCandidate()rejects candidates added beforesetRemoteDescription(). With trickle, buffer early candidates until the remote description is set.
What works: any pair of cone NATs (the common home-router case) connects directly over srflx candidates after hole punching. Two devices on the same LAN connect over host candidates. What does not: symmetric NAT on both sides, a symmetric NAT facing a port-restricted one, or a firewall that blocks UDP and the STUN reply.
A TURN relay would connect the symmetric-NAT cases that STUN cannot, at the cost of routing gameplay through a server. This project rejects that trade on purpose: data must stay peer-to-peer over RTCDataChannel, so no relay is shipped. A share of users behind symmetric or carrier-grade NAT will not connect, and that is a known, accepted limit rather than a bug to hide. If a connection reaches failed with only typ host and no working typ srflx pair, this is almost always the cause.
See the protocols page for STUN versus TURN, the signaling handshake that carries candidates and trickle messages, and the connection state machine for how iceConnectionState evolves during these checks.