Backend
Your Browser Is Behind a Router. Let's See What It Actually Knows.
Rittly Labs Dev.to (EN Zone)
2 views
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.
Read original: https://dev.to/rittlyofficial/your-browser-is-behind-a-router-lets-see-what-it-actually-knows-4jbn
← Previous
Your agents don't need a jailbreak to go rogue
Next →
The AI Skill Most People Skip: Building a Verification Loop
Related
Should Your Thread Keep the JVM Alive?
Backend
0
Dev.to (EN Zone)
Monitoring Jetson CPU, GPU, Temperature and Power from a Flutter Robot Dashboard
Backend
3
DEV Community
Stop Slamming Downstream Services: Singleflight Request Coalescing with Java Virtual Threads
Backend
1
DEV Community
Nothing could start without the dependency we had filed as optional
Backend
2
DEV Community
Comments0
No comments yet — be the first