In the Medium(https://medium.com/@rittlyofficial/your-browser-is-behind-a-router-now-what-e0154fe778aa) article, we looked at a deceptively simple problem: Alice's browser knows its address. Bob's browser knows its address. Both browsers are online. And yet, neither browser can simply use the other's private IP address. The reason is that an IP address does not automatically mean reachability. Now let's stop talking about the problem for a moment. Let's ask the browser what it actually knows. We'll build a small WebRTC playground, run it locally, and inspect the network candidates that the browser gathers. By the end of this article, you should be able to answer: What is an ICE candidate? What is a host candidate? Why does a browser sometimes discover a srflx candidate? What does STUN actually tell the browser? Why does gathering candidates not mean that two browsers are connected? Let's start. 1. Prerequisites You don't need to run a WebRTC backend or signaling server for this experiment. You only need: a modern browser Python 3 a terminal a text editor internet access for the STUN part of the experiment We'll use plain HTML and JavaScript. No framework. No Node.js. No signaling server. No backend. The goal is to see what the browser itself can discover. 2. Create the playground Create a directory: mkdir webrtc-connectivity-playground cd webrtc-connectivity-playground Create the HTML file: touch index.html If you're using an editor, you can simply create index.html manually. Add the following code: <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>WebRTC Connectivity Playground</title> <style> body { font-family: system-ui, sans-serif; max-width: 1000px; margin: 40px auto; padding: 0 20px; line-height: 1.5; } button { padding: 10px 16px; font-size: 16px; cursor: pointer; } table { width: 100%; border-collapse: collapse; margin-top: 24px; } th, td { text-align: left; padding: 10px; border-bottom: 1px solid #ddd; vertical-align: top; } code { word-break: break-word; } pre { margin-top: 24px; padding: 16px; background: #f4f4f4; border-radius: 8px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; } #status { margin-top: 20px; font-weight: 600; } </style> </head> <body> <h1>WebRTC Connectivity Playground</h1> <p> Ask the browser to gather the network candidates it knows about. </p> <button id="start"> Discover Candidates </button> <div id="status"> Waiting... </div> <table> <thead> <tr> <th>Type</th> <th>Address</th> <th>Port</th> <th>Protocol</th> </tr> </thead> <tbody id="candidates"></tbody> </table> <pre id="raw">No candidates yet.</pre> <script> const button = document.getElementById("start"); const status = document.getElementById("status"); const candidates = document.getElementById("candidates"); const raw = document.getElementById("raw"); button.addEventListener("click", async () => { button.disabled = true; status.textContent = "Gathering ICE candidates..."; candidates.innerHTML = ""; raw.textContent = ""; const pc = new RTCPeerConnection({ iceServers: [ { urls: "stun:stun.l.google.com:19302" } ] }); pc.createDataChannel("probe"); pc.addEventListener("icecandidate", (event) => { if (!event.candidate) { return; } const candidate = event.candidate; const row = document.createElement("tr"); row.innerHTML = ` <td> <code>${candidate.type ?? "unknown"}</code> </td> <td> <code>${candidate.address ?? "hidden"}</code> </td> <td> <code>${candidate.port ?? "—"}</code> </td> <td> <code>${candidate.protocol ?? "—"}</code> </td> `; candidates.appendChild(row); raw.textContent += candidate.candidate + "\n\n"; }); pc.addEventListener("icegatheringstatechange", () => { if (pc.iceGatheringState === "complete") { status.textContent = "ICE gathering complete."; } }); try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); } catch (error) { status.textContent = `Error: ${error.message}`; } }); </script> </body> </html> 3. Run it From the same directory, run: python3 -m http.server 8000 You should see something similar to: Serving HTTP on 0.0.0.0 port 8000 Now open: http://localhost:8000 You should see the playground. Click: Discover Candidates After a moment, the table should start filling with candidates. 4. What should you see? Depending on your browser and network, you may see something like: Type Address Port Protocol host 192.168.1.20 54321 udp srflx 203.0.113.50 62001 udp Your values will almost certainly be different. You may see only: host You may see: host srflx And you may see a .local hostname instead of a private IP address. All of these can be normal. The result depends on: your network your router your browser VPN configuration firewall policies browser privacy mechanisms The important part is understanding what the candidate types mean. 5. What is an ICE candidate? Let's start with the simplest definition. An ICE candidate is information describing a possible way for a browser to communicate. A candidate can contain information such as: Address Port Protocol Candidate type For example: 192.168.1.20:54321 is an address and port. The complete candidate string contains more information, which is why the raw output can look intimidating: candidate:... Don't worry about decoding every field yet. For this article, we care mainly about the candidate type. 6. The host candidate The first candidate type you'll usually encounter is: host A host candidate represents an address available directly from the local machine or one of its network interfaces. For example: 192.168.1.20:54321 Conceptually: Alice's Laptop 192.168.1.20 │ ▼ Network If Alice and Bob are on the same local network, their browsers may be able to communicate using local addresses. For example: Alice Bob 192.168.1.20 192.168.1.21 │ │ └──────── Wi-Fi ─────────┘ That can be a perfectly valid path. But now put them on different networks. Alice 192.168.1.20 │ ▼ Router │ ▼ Internet │ ▼ Router │ ▼ Bob 192.168.0.15 Alice's private address doesn't tell Bob how to reach her across the internet. That's the problem we started with. 7. Let's inspect the candidate object The browser gives us more than the raw candidate string. We can inspect the candidate object directly. Open your browser's DevTools console and change the event handler to: pc.addEventListener("icecandidate", (event) => { if (!event.candidate) { return; } console.log({ candidate: event.candidate.candidate, type: event.candidate.type, address: event.candidate.address, port: event.candidate.port, protocol: event.candidate.protocol }); }); Now run the experiment again. You should see an object similar to: { candidate: "...", type: "host", address: "...", port: 54321, protocol: "udp" } The exact values depend on your environment. This is much easier to reason about than the complete candidate string. 8. Why does setLocalDescription() matter? Look at these lines: const offer = await pc.createOffer(); await pc.setLocalDescription(offer); It is tempting to think that createOffer() is what gathers the candidates. That's not quite the right mental model. The local description is set, and ICE gathering begins as part of the peer connection process. The browser then reports candidates through: icecandidate So our experiment is essentially: Create RTCPeerConnection │ ▼ Create data channel │ ▼ Create offer │ ▼ Set local description │ ▼ ICE gathering │ ▼ icecandidate events We're watching the browser discover possible network addresses. 9. Why did we create a data channel? You may have noticed this: pc.createDataChannel("probe"); We aren't actually sending data through it. That's intentional. The data channel gives the peer connection something to negotiate, allowing us to create an offer for this experiment. Think of it as giving the WebRTC connection a small piece of work to prepare for. We're not establishing a connection to another browser yet. 10. Where does NAT enter the picture? Now let's return to Alice. Her laptop might have: 192.168.1.20:54321 Her router may translate that connection to something like: 203.0.113.50:62001 Conceptually: Alice Browser 192.168.1.20:54321 │ ▼ Router │ ▼ 203.0.113.50:62001 The browser does not necessarily own the public-side address. The router does the NAT translation. So the browser has a problem: How can I discover how I appear from outside my network? This is where STUN becomes useful. 11. Add a STUN server Our playground already contains a STUN server: const pc = new RTCPeerConnection({ iceServers: [ { urls: "stun:stun.l.google.com:19302" } ] }); The important part is: iceServers We're telling the WebRTC implementation: "You may use this server when gathering ICE candidates." STUN stands for: Session Traversal Utilities for NAT At a high level, STUN allows the browser to ask an external server what address and port the request appeared to come from. Conceptually: Alice Browser 192.168.1.20:54321 │ ▼ Router │ ▼ STUN Server │ ▼ 203.0.113.50:62001 The STUN server can observe the public-side mapping. The browser can then use that information as another candidate. 12. The srflx candidate If STUN successfully discovers a server-reflexive address, you may see: srflx srflx is short for: server-reflexive Conceptually: host 192.168.1.20:54321 and: srflx 203.0.113.50:62001 Now the browser has two different pieces of information: host ↓ local address srflx ↓ public-side address observed through STUN This is a big improvement. But there is still an important limitation. 13. A candidate is not a connection This is worth repeating. Seeing: srflx 203.0.113.50:62001 does not mean Bob can successfully connect to Alice using that address. It means: The browser discovered a possible address. That's all. We haven't: exchanged candidates with another browser built candidate pairs performed connectivity checks selected a working path configured TURN Those are separate steps. This distinction is extremely important when debugging WebRTC. Candidate gathering │ ▼ "Here are possible addresses." Connectivity checking │ ▼ "Let's see which paths actually work." Our playground currently stops at the first part. 14. What if I don't see 192.168.x.x? This is a common point of confusion. You may expect to see: 192.168.1.20 but instead see something like: something-random.local Modern browsers can use mDNS hostnames for local candidates instead of exposing the local IP address directly. So don't treat: 192.168.x.x as a requirement for the experiment to work. Look at: candidate.type If you see: host the browser has gathered a host candidate. The exact representation of the address can vary. 15. Try it on different networks Now the experiment gets more interesting. Run the playground on your normal Wi-Fi connection. Record what you see. Then try another network. For example: Wi-Fi ↓ Gather candidates and then: Mobile hotspot ↓ Gather candidates You may see different candidate information. You can also try: another browser a VPN Ethernet another computer The point isn't to produce one perfect output. The point is to observe that the network environment affects what the browser can discover. 16. What we have learned Let's summarize what our little playground has shown us. A browser can have multiple network interfaces and addresses. WebRTC gathers possible network candidates from that environment. The simplest candidate type is: host A STUN server can help the browser discover a server-reflexive candidate: srflx And the browser exposes these candidates through the: icecandidate event. Our current flow looks like this: Network interfaces │ ▼ ICE gathering │ ├──────────────┐ ▼ ▼ host srflx │ │ │ └── STUN │ └── local interface But we're still missing the most important part. Another browser. 17. One browser isn't enough Our playground asks: "What addresses can I discover about myself?" A real WebRTC connection needs two peers. For example: Alice Browser Bob Browser host host srflx srflx │ │ │ │ └──────── Candidates ────────┘ Now the browsers have to exchange those candidates. Then they need to test whether the possible paths actually work. Only after that can WebRTC select a usable path. That is where ICE connectivity checks enter the picture. And that's where we'll continue in the next article. 18. The important mental model At this point, don't try to memorize candidate grammar. Remember this: The browser │ ▼ "What network addresses do I have?" │ ▼ Candidate gathering │ ├── host │ └── srflx │ └── discovered using STUN The browser has now collected possibilities. It hasn't proved that any of them can reach the other browser. That's the next problem. 19. Where we are in the WebRTC journey We've gone from: Private IP │ ▼ NAT │ ▼ "What does the outside world see?" │ ▼ STUN │ ▼ ICE candidates The next step is: Alice candidates + Bob candidates │ ▼ Candidate pairs │ ▼ Connectivity checks │ ▼ Working path And if direct connectivity isn't possible, we'll eventually need another option: TURN But that's the next layer. For now, we've accomplished something useful. We made the browser's hidden networking process visible. In Part 2, we'll finally put Alice and Bob together. We'll exchange their candidates and watch ICE test the possible paths. We'll look at: candidate pairs connectivity checks ICE states successful and failed paths selected candidate pairs STUN in the complete connection flow TURN and relay candidates The browser has found some addresses. Next, we'll find out which one actually works.