How Does WebRTC Connect Two Devices Behind Different Routers?
TL;DR
- Two devices behind routers can't reach each other directly. Private IP addresses mean nothing outside the LAN, and each router drops any inbound packet that isn't a reply to something it sent.
- WebRTC works around this by having both devices send outbound traffic at once (UDP hole punching) after using STUN to learn each device's public address and a signaling server to exchange those addresses.
- ICE tests every candidate pair and settles on a working route, direct when possible, or a TURN relay when a router won't cooperate, such as with symmetric NAT, blocked UDP, or carrier-grade NAT.
- All of this happens inside RTCPeerConnection with no plugins or manual port forwarding, and it typically connects in under a second.
Nearly every device on a WebRTC call is behind a router. The device's own IP address is private and means nothing to the outside world, and the router drops any inbound packet that isn't a reply to something the device sent. Two devices in that position have no way to reach each other directly.
WebRTC gets around this by having both devices send outbound traffic simultaneously. Each router sees its own device start a conversation and lets the replies in, so if both sides start at once, packets flow in both directions.
The rest is plumbing. Each device asks a STUN server what its traffic looks like from the public internet; the two exchange those addresses through a signaling server; and ICE runs simultaneous sends until the routers let the packets through. When a router refuses even that, a TURN server relays the encrypted packets instead. It usually takes less than a second, with no port forwarding or router configuration required.
Why Can't Two Devices Behind Routers Just Send Packets to Each Other?
Two devices behind routers can't reach each other because neither one has an address the other can use, and each router drops anything that isn't a reply to traffic it sent out.
Suppose a laptop on one home network wants to call a phone on another, each behind its own router.
| The laptop | The phone | |
|---|---|---|
| Private address of the device | 192.168.1.20 | 10.0.0.7 |
| Public address of its router | 203.0.113.10 | 198.51.100.20 |
Addresses starting with 192.168 or 10 are private. They're reused on millions of networks, and no router on the public internet will forward a packet toward one. That is what your firewall is designed to stop. So the laptop can't send anything to the actual phone at 10.0.0.7. The only publicly accessible address on the phone's side is 198.51.100.20, and it belongs to the phone network's router, not the phone.
So instead, both routers do Network Address Translation (NAT). It works the same way on both sides, so follow one packet from the laptop.
- The laptop sends a packet to a server somewhere on the internet. The packet's source address is the laptop's private address, 192.168.1.20.
- The laptop's router swaps that source address for its own public address, 203.0.113.10, plus a port it picks for this conversation, then sends the packet on its way.
- The router logs what it just did, so it knows that anything coming back on that port belongs to the laptop.
- When the server replies to 203.0.113.10 on that port, the router looks up its routing table entry, swaps the address back, and delivers the reply to the laptop.
That saved note is a NAT mapping, and a router only creates one when a device on the inside sends something out.
The phone's router works the same way, and right now it has no mapping pointing to the phone. So when the laptop's packet arrives at 198.51.100.20, it doesn't match anything, and the router drops it.
Most routers also run a firewall that drops unsolicited inbound traffic anyway, and phones on mobile networks usually sit behind a second layer of NAT run by the carrier. None of this is aimed at WebRTC. Routers drop anything that isn't a reply to traffic from the inside, so a WebRTC connection has to start with outbound traffic from both sides.
How Do Devices Exchange Connection Details Before They Connect?
They go through a server both of them can already reach, called a signaling server. WebRTC leaves this part entirely to the application. Each device opens an ordinary connection to the server, usually over a WebSocket, though HTTPS polling or SIP work too. That link is the signaling channel, and it carries connection details in both directions until the devices can talk directly.
The details travel as SDP, the Session Description Protocol. The laptop creates an RTCPeerConnection, adds its camera and microphone tracks, and produces an offer, which is a text description of the session it wants.
The offer covers a handful of things:
- It lists the audio and video codecs the laptop can send and receive, such as Opus, VP8, H.264, or AV1.
- It says whether the laptop intends to send media, receive it, or both, and how many media streams are involved.
- It carries a username fragment and password that ICE will use to authenticate its connectivity checks.
- It includes a fingerprint of the temporary certificate that the laptop will present during the DTLS handshake later.
- It can include any network addresses the laptop has already discovered, though those usually follow separately.
The offer goes to the phone over the signaling channel. The phone reads it, works out which of those options it supports, and returns an SDP answer with its chosen codecs, its own ICE credentials, its own certificate fingerprint, and any addresses it has gathered. The server forwards the answer back to the laptop.
Stream's signaling server tutorial builds one in Node.js using Socket.IO, and the SDP messages lesson goes into the offer-and-answer exchange in more detail.
How Does Each Device Find Out Its Public Address?
It asks a STUN server. STUN stands for Session Traversal Utilities for NAT, and its one job is telling a device what its traffic looks like from the public internet. That view is the only address the other side could ever use.
The laptop opens a UDP socket on 192.168.1.20:53000 and sends a small STUN binding request from it to a STUN server on the public internet. As that packet leaves the home network, the laptop's router creates a NAT mapping and rewrites the source address.
Inside the network: 192.168.1.20:53000
On the internet: 203.0.113.10:62000The server sees the packet arrive from 203.0.113.10:62000 and sends that address back to the laptop in its reply. Nothing else happens. STUN doesn't open anything on the router or forward any media. It reports how the router presented the packet and how it created that mapping on its own the moment the packet went out.
Port 62000 was chosen by the laptop's router, not by WebRTC or by STUN, and it's temporary. The phone goes through the same steps and learns that its socket on 10.0.0.7:50000 appears to the internet as 198.51.100.20:41000.
Each address a device can be reached at is called an ICE candidate, and by the end of gathering, each device usually has three.
| Candidate type | Phone example | Where it comes from | When it works |
|---|---|---|---|
| Host | 10.0.0.7:50000 | The device's own network interface | When both devices are on the same local network |
| Server reflexive | 198.51.100.20:41000 | STUN's reply | Across the internet, if both routers cooperate |
| Relay | 192.0.2.50:55000 | An allocation on a TURN server | Whenever the device can reach the relay at all |
The relay candidate is gathered alongside the others rather than after a direct attempt has failed. Each device contacts a TURN server, authenticates, and asks it to allocate a public address that forwards to that device. Having the allocation ready up front is what keeps the fallback quick when it's needed.
Gathering takes time, so browsers use trickle ICE. They send the offer or answer as soon as it's ready, then push each ICE candidate through the signaling channel as it's discovered, rather than holding everything until the list is complete. Modern browsers also replace the private IP address in host candidates with an mDNS name ending in .local, so a web page can't read the local address of every visitor. The process underneath is the same.
Stream's ICE candidates lesson covers gathering and exchanging candidates in code.
How Does ICE Get Packets Through Both Routers?
ICE gets packets through by having both devices send to each other simultaneously. Each router sees its own device start an ordinary outbound conversation and lets the replies back in, and that trick is UDP hole punching.
Each device pairs every local candidate with every remote one, sorts the pairs so that direct routes are tried before relayed ones, and then starts testing. A test is called a connectivity check, and it's nothing exotic, just a small STUN binding request sent from one device straight at the other, carrying the ICE username and password from the SDP so the far side knows who it's from.
Here's the whole exchange for the direct route, from the first STUN request to encrypted media.

The pair that matters for our two devices is the laptop's server-reflexive candidate against the phone's, since the host candidates are private addresses that can't cross the internet.
Watch what happens when the checks go out:
- The laptop sends a check from its local socket at 192.168.1.20:53000 to the phone's public address, 198.51.100.20:41000. On the way out, the laptop's router rewrites the source to 203.0.113.10:62000 and records that this mapping has sent traffic to that address.
- If that check reaches the phone's router before the phone has sent anything, it gets dropped. The router has no note saying anyone inside asked for this.
- Meanwhile, the phone sends its own check the other way, from 10.0.0.7:50000 to 203.0.113.10:62000. Its router rewrites the source to 198.51.100.20:41000 and makes the matching note pointing at the laptop.
- The phone's check reaches the laptop's router, and this time there's a note that fits. The laptop just sent traffic to exactly this address, so the router translates the destination back to 192.168.1.20:53000 and forwards the packet to the laptop, which responds.
- ICE keeps retransmitting checks that got no reply, so the laptop's next attempt lands at the phone's router after the phone's note exists and gets through the same way. Now packets flow in both directions.
Neither router opened a port in any lasting sense. Each one saw its own device start what looks like a normal outbound conversation and, for a while, let replies from the far end back in. The only unusual part is that both devices started the conversation at the same time, on purpose.
Once a check succeeds in both directions, the two devices settle on that pair. One of them holds the ICE controlling role; it nominates the winner, and both sides adopt it as the route. From then on, everything in the call travels over that one combination of source and destination IP addresses, ports, and protocol. That combination is the five-tuple you'll see in packet captures.
Finding the route still doesn't mean media can flow. First, the devices perform a DTLS (Datagram Transport Layer Security) handshake over the selected pair, and each checks that the other end's certificate matches the fingerprint in the SDP. The handshake produces the keys for SRTP, which encrypts every RTP packet of audio and video, and data channels ride along over SCTP inside the same encrypted session. So ICE finds the route, DTLS agrees the keys, and SRTP protects the media.
The route also needs upkeep, because NAT mappings expire when they sit idle, sometimes after as little as 30 seconds of silence. So WebRTC keeps the route busy. Alongside the RTP packets, it sends a small ICE consent check every few seconds, and those checks serve two purposes.
- They keep the notes on both routers up to date so the route stays open.
- They confirm the other device still wants the traffic. If the answers stop coming, the connection moves to disconnected and eventually to failed.
Stream's peer-to-peer architecture lesson shows the same flow in code, including how to handle a failed connection.
What Happens When a Direct Route Can't Be Made?
ICE falls back to a TURN relay, where both devices send their traffic to a server with a public address, and the server forwards it between them. The call connects anyway, at the cost of an extra hop.
Hole punching relies on both routers behaving in a fairly relaxed way, and plenty don't. Three situations account for most of the failures.
- The router picks a different public port for every destination, which is what people mean by symmetric NAT. The phone told the laptop to send to port 41000, because that's the port its router happened to use for the STUN server. For traffic to the laptop, though, the router uses 41001, so the laptop's checks land on a port where nobody is listening.
- A firewall blocks UDP outright, which is normal on corporate and guest networks.
- One or both devices sit behind carrier-grade NAT, an extra translation layer the carrier runs in front of the router, and stacked layers of NAT rarely produce notes that line up.
One symmetric side is sometimes survivable. The other device notices the unexpected port on the incoming check, adds it as a peer-reflexive candidate, and replies there. With two symmetric sides, or no UDP at all, the direct options are done, and ICE falls back to the relay pairs.
TURN stands for Traversal Using Relays around NAT, and this fallback is why the relay candidate was gathered back at the start. The phone's relay candidate, 192.0.2.50:55000, is a public address on the TURN server that forwards to the phone. The laptop sends its packets there, and the server passes them down the connection the phone already holds open. Both routers see nothing but ordinary outbound traffic to a public server, which is why this always works.
| Direct route | TURN relay | |
|---|---|---|
| Path the media takes | The laptop's router to the phone's router across the internet | Both devices to the relay, which forwards between them |
| Added latency | None beyond the network path itself | One extra hop, typically 10 to 30 ms |
| Who pays for the bandwidth | Each device's own connection | Whoever runs the TURN server, for every byte in both directions |
| Encryption | End-to-end between the two devices | The same end-to-end session. The relay forwards packets it can't decrypt |
| Share of connections | Most of them | Somewhere around 10 to 20 percent in most published numbers |
The connection to the TURN server can run over UDP, TCP, or TLS, and TLS on port 443 is the one that survives the strictest networks because it can't be distinguished from ordinary HTTPS. ICE still prefers a UDP path to the relay when it can get one, since TCP makes every lost packet hold up the packets behind it. And TURN servers require credentials, usually short-lived ones issued by the app's backend, so strangers can't use them as a free relay.
Group calls are a different story, because participants never connect in the first place. Each device runs this same ICE process against an SFU, a media server with a public IP address of its own, and that removes half the problem. The device's outbound checks create the note on its own router, the server replies to whatever address they came from, and even a symmetric NAT is fine. TURN only comes into play when the network blocks UDP.
What Does the Connection Process Look Like in the WebRTC API?
It lives in RTCPeerConnection. WebRTC ships as a set of JavaScript APIs with no plugins to install, and everything above happens inside the browser when you use them. This is the laptop's side of a 1:1 WebRTC connection, since it makes the offer, using a WebSocket signaling channel.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "turn:turn.example.com:3478", username: "user", credential: "secret" },
{ urls: "turns:turn.example.com:443", username: "user", credential: "secret" },
],
});
// Capture the local MediaStream and hand each of its tracks to the peer
// connection. addTrack() returns an RTCRtpSender for the track.
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
for (const track of stream.getTracks()) {
pc.addTrack(track, stream);
}
// Remote media arrives as tracks, each with an RTCRtpReceiver behind it.
pc.ontrack = (event) => {
remoteVideo.srcObject = event.streams[0];
};
// Data channels ride on the same connection over SCTP.
const chat = pc.createDataChannel("chat");
// Every ICE candidate the browser gathers goes to the other peer over signaling.
pc.onicecandidate = (event) => {
if (event.candidate) {
signaling.send(JSON.stringify({ candidate: event.candidate }));
}
};
// Create the offer and apply it as the local description. Applying it is
// what starts ICE gathering.
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send(JSON.stringify({ description: pc.localDescription }));
// Messages coming back from the other peer.
signaling.onmessage = async ({ data }) => {
const message = JSON.parse(data);
if (message.description) {
await pc.setRemoteDescription(message.description);
} else if (message.candidate) {
await pc.addIceCandidate(message.candidate);
}
};The iceServers list is where STUN and TURN come in. The stun: entry gives the browser a server to ask for its server-reflexive candidate, the turn: entry with credentials gets it a relay allocation, and the turns: entry on port 443 is the transport of last resort for locked-down networks.
getUserMedia returns a MediaStream, and addTrack attaches each track to the peer connection so that the offer describes it. Screen sharing works the same way. The track from getDisplayMedia passes through addTrack and uses the same ICE route as the camera, so there's no need to negotiate a second connection. createDataChannel returns an RTCDataChannel that rides along too.
Calling createOffer builds the SDP, and setLocalDescription applies it and starts the browser gathering candidates. Each one fires onicecandidate, which is where trickle ICE happens in practice. On the phone, the offer arrives first.
await pc.setRemoteDescription(message.description);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signaling.send(JSON.stringify({ description: pc.localDescription }));Every candidate that comes in over signaling goes through addIceCandidate so that ICE can pair it with the local ones, and the connectivity checks start as soon as there's a pair to test. Back on the laptop, setRemoteDescription with the answer completes the exchange. RTCPeerConnection exposes the state of all this through a few properties.
| Property and event | What it tracks | Values you'll see during setup |
|---|---|---|
| signalingState | Where the offer and answer exchange is | stable, have-local-offer, have-remote-offer |
| iceGatheringState, via icegatheringstatechange | Whether the browser is still discovering candidates | new, gathering, complete |
| iceConnectionState | Whether ICE has found a working pair | checking, connected, completed, disconnected, failed |
| connectionState, via connectionstatechange | ICE and DTLS together, which is the one most apps should watch | connecting, connected, disconnected, failed |
To see which route the call ended up on, ask getStats for the nominated candidate pair.
const stats = await pc.getStats();
for (const report of stats.values()) {
if (report.type === "candidate-pair" && report.state === "succeeded" && report.nominated) {
const local = stats.get(report.localCandidateId);
const remote = stats.get(report.remoteCandidateId);
console.log(local.candidateType, "->", remote.candidateType);
// "srflx -> srflx" is a direct route through both NATs.
// "relay" on either side means the media is going through TURN.
}
}When the connectionState reaches failed, the route is gone, and the fix is an ICE restart.
pc.onconnectionstatechange = () => {
if (pc.connectionState === "failed") {
pc.restartIce();
}
};restartIce marks the connection for new ICE credentials and fires negotiationneeded, so the app creates a fresh offer, sends it through signaling, and the whole gathering and checking process runs again.
The same ICE restart is worth triggering when a device changes networks. If the phone switches from Wi-Fi to cellular, it gets a new public IP address, and its old NAT mappings stop working. A short, disconnected state usually recovers on its own, so the failed one is the one to act on.
When the call ends and pc.close() runs, the checks stop, the TURN allocation is released, and the entries on both routers time out. Ports 62000 and 41000 return to their routers' pools, and nothing about the connection is left behind.
Stream's RTCPeerConnection lesson covers the rest of the object, and the RTCDataChannel lesson covers data channels in detail.
