DEV Community

Rittly Labs
Rittly Labs

Posted on

Your Browser Found an Address. Now Let's Find a Path.

Part 2 of the WebRTC Blog Series — The Lab

In Part 1, we asked a deceptively simple question:

What address does my browser have?

We discovered that the answer isn't always as simple as 192.168.x.x.

Your browser can discover local addresses, public-facing addresses through STUN, and other possible network candidates.

But there's a problem.

An address isn't a path.

So in this lab, we're going one step further.

We'll let two browser tabs exchange their candidates, form candidate pairs, run ICE connectivity checks, and see when the browser finds a path that actually works.


What We're Building

We'll build a tiny WebRTC connectivity playground.

You'll have:

Browser A
   │
   │ candidates
   ▼
Signaling
   │
   │ candidates
   ▼
Browser B
   │
   ▼
Candidate Pairs
   │
   ▼
ICE Connectivity Checks
   │
   ▼
Working Path
Enter fullscreen mode Exit fullscreen mode

There is one important shortcut here.

We're using the browser's BroadcastChannel API as our signaling mechanism.

That means this is a local experiment, not a production signaling system.

You don't need a backend server.

You only need:

  • One computer
  • A modern browser
  • Two browser tabs
  • Python 3

1. Create the Playground

Create a directory:

mkdir webrtc-connectivity-playground
cd webrtc-connectivity-playground
Enter fullscreen mode Exit fullscreen mode

Create an index.html file:

touch index.html
Enter fullscreen mode Exit fullscreen mode

We'll put the entire experiment in that one file.


2. The Complete Lab

Copy this into index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />

  <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;
    }

    h1 {
      margin-bottom: 8px;
    }

    .controls {
      display: flex;
      gap: 10px;
      margin: 20px 0;
      flex-wrap: wrap;
    }

    button {
      padding: 10px 16px;
      border: 1px solid #ccc;
      border-radius: 6px;
      cursor: pointer;
      background: white;
    }

    button:disabled {
      cursor: not-allowed;
      opacity: 0.5;
    }

    .status {
      padding: 12px;
      border: 1px solid #ddd;
      border-radius: 8px;
      margin: 20px 0;
    }

    pre {
      background: #f5f5f5;
      padding: 16px;
      border-radius: 8px;
      overflow-x: auto;
      white-space: pre-wrap;
    }

    .section {
      margin-top: 28px;
    }
  </style>
</head>

<body>

  <h1>WebRTC Connectivity Playground</h1>

  <p>
    Open this page in exactly two browser tabs.
    Choose <strong>Caller</strong> in one tab and
    <strong>Receiver</strong> in the other.
  </p>

  <div class="controls">
    <button id="callerBtn">Caller</button>
    <button id="receiverBtn">Receiver</button>

    <button id="inspectBtn" disabled>
      Inspect Candidate Pairs
    </button>
  </div>

  <div class="status">
    <strong>Role:</strong>
    <span id="role">Not selected</span>
    <br />

    <strong>ICE gathering:</strong>
    <span id="gathering">new</span>
    <br />

    <strong>ICE connection:</strong>
    <span id="ice">new</span>
    <br />

    <strong>Connection:</strong>
    <span id="connection">new</span>
  </div>

  <div class="section">
    <h2>ICE Candidates</h2>
    <pre id="candidates">Waiting...</pre>
  </div>

  <div class="section">
    <h2>Logs</h2>
    <pre id="logs">Waiting...</pre>
  </div>

  <div class="section">
    <h2>Candidate Pair Statistics</h2>
    <pre id="stats">Click "Inspect Candidate Pairs" after connecting.</pre>
  </div>

<script>
  const signaling = new BroadcastChannel(
    "webrtc-connectivity-lab"
  );

  const pc = new RTCPeerConnection({
    iceServers: [
      {
        urls: "stun:stun.l.google.com:19302"
      }
    ]
  });

  let role = null;
  let pendingCandidates = [];
  let pendingOffer = null;

  const callerBtn = document.getElementById("callerBtn");
  const receiverBtn = document.getElementById("receiverBtn");
  const inspectBtn = document.getElementById("inspectBtn");

  const roleEl = document.getElementById("role");
  const gatheringEl = document.getElementById("gathering");
  const iceEl = document.getElementById("ice");
  const connectionEl = document.getElementById("connection");

  const candidatesEl = document.getElementById("candidates");
  const logsEl = document.getElementById("logs");
  const statsEl = document.getElementById("stats");

  function log(message) {
    const time = new Date().toLocaleTimeString();

    logsEl.textContent += `[${time}] ${message}\n`;
    logsEl.scrollTop = logsEl.scrollHeight;
  }

  function showCandidate(candidate) {
    const type = candidate.type || "unknown";
    const protocol = candidate.protocol || "unknown";
    const address =
      candidate.address || candidate.candidate || "unknown";
    const port = candidate.port || "";

    candidatesEl.textContent +=
      `${type} | ${protocol} | ${address}:${port}\n`;
  }

  async function setRole(selectedRole) {
    if (role) return;

    role = selectedRole;

    roleEl.textContent = role;

    callerBtn.disabled = true;
    receiverBtn.disabled = true;

    log(`Role selected: ${role}`);

    if (role === "Caller") {
      await createOffer();
    }

    if (role === "Receiver" && pendingOffer) {
      await handleOffer(pendingOffer);
      pendingOffer = null;
    }
  }

  callerBtn.addEventListener("click", () => {
    setRole("Caller");
  });

  receiverBtn.addEventListener("click", () => {
    setRole("Receiver");
  });

  pc.onicecandidate = (event) => {
    if (!event.candidate) return;

    showCandidate(event.candidate);

    log(
      `Candidate gathered: ${event.candidate.type || "unknown"}`
    );

    signaling.postMessage({
      type: "candidate",
      candidate: event.candidate.toJSON()
    });
  };

  pc.onicegatheringstatechange = () => {
    gatheringEl.textContent = pc.iceGatheringState;

    log(
      `ICE gathering state: ${pc.iceGatheringState}`
    );
  };

  pc.oniceconnectionstatechange = () => {
    iceEl.textContent = pc.iceConnectionState;

    log(
      `ICE connection state: ${pc.iceConnectionState}`
    );

    if (
      pc.iceConnectionState === "connected" ||
      pc.iceConnectionState === "completed"
    ) {
      inspectBtn.disabled = false;
    }
  };

  pc.onconnectionstatechange = () => {
    connectionEl.textContent = pc.connectionState;

    log(
      `Peer connection state: ${pc.connectionState}`
    );
  };

  /*
   * The Caller creates the DataChannel.
   * The Receiver gets it through the datachannel event.
   */
  pc.ondatachannel = (event) => {
    const channel = event.channel;

    log("DataChannel received.");

    channel.onopen = () => {
      log("DataChannel opened.");

      channel.onmessage = (messageEvent) => {
        log(
          `Message received: ${messageEvent.data}`
        );
      };
    };
  };

  async function handleOffer(description) {
    try {
      log("Offer received.");

      await pc.setRemoteDescription(description);

      await flushPendingCandidates();

      const answer = await pc.createAnswer();

      await pc.setLocalDescription(answer);

      signaling.postMessage({
        type: "answer",
        description: pc.localDescription
      });

      log("Answer sent.");

    } catch (error) {
      log(`Offer handling error: ${error.message}`);
      console.error(error);
    }
  }

  signaling.onmessage = async (event) => {
    const message = event.data;

    try {
      if (message.type === "offer") {
        if (role !== "Receiver") {
          pendingOffer = message.description;

          log(
            "Offer received and waiting for Receiver role."
          );

          return;
        }

        await handleOffer(message.description);
      }

      if (message.type === "answer") {
        if (role !== "Caller") return;

        log("Answer received.");

        await pc.setRemoteDescription(
          message.description
        );

        await flushPendingCandidates();
      }

      if (message.type === "candidate") {
        await handleRemoteCandidate(
          message.candidate
        );
      }

    } catch (error) {
      log(`Signaling error: ${error.message}`);
      console.error(error);
    }
  };

  async function handleRemoteCandidate(candidate) {
    if (pc.remoteDescription) {
      await pc.addIceCandidate(candidate);

      log("Remote candidate added.");
    } else {
      pendingCandidates.push(candidate);

      log("Remote candidate queued.");
    }
  }

  async function flushPendingCandidates() {
    for (const candidate of pendingCandidates) {
      await pc.addIceCandidate(candidate);
    }

    if (pendingCandidates.length > 0) {
      log(
        `Added ${pendingCandidates.length} queued candidate(s).`
      );
    }

    pendingCandidates = [];
  }

  async function createOffer() {
    try {
      log("Creating offer...");

      const channel = pc.createDataChannel("probe");

      channel.onopen = () => {
        log("DataChannel opened.");

        channel.send(
          "Hello from the other side!"
        );
      };

      const offer = await pc.createOffer();

      await pc.setLocalDescription(offer);

      signaling.postMessage({
        type: "offer",
        description: pc.localDescription
      });

      log("Offer sent.");

    } catch (error) {
      log(`Offer error: ${error.message}`);
      console.error(error);
    }
  }

  inspectBtn.addEventListener("click", async () => {
    try {
      const stats = await pc.getStats();

      let output = "";

      stats.forEach((report) => {
        if (report.type !== "candidate-pair") {
          return;
        }

        output +=
          `state: ${report.state || "n/a"}\n` +
          `localCandidateId: ${
            report.localCandidateId || "n/a"
          }\n` +
          `remoteCandidateId: ${
            report.remoteCandidateId || "n/a"
          }\n` +
          `currentRoundTripTime: ${
            report.currentRoundTripTime ?? "n/a"
          }\n` +
          `bytesSent: ${
            report.bytesSent ?? "n/a"
          }\n` +
          `bytesReceived: ${
            report.bytesReceived ?? "n/a"
          }\n\n`;
      });

      statsEl.textContent =
        output || "No candidate-pair reports found.";

    } catch (error) {
      statsEl.textContent =
        `Stats error: ${error.message}`;

      console.error(error);
    }
  });
</script>

</body>
</html>
Enter fullscreen mode Exit fullscreen mode

There's a lot happening here.

Don't worry.

We're going to peel it apart one piece at a time.


3. Start the Lab

From the same directory:

python3 -m http.server 8000
Enter fullscreen mode Exit fullscreen mode

Open:

http://localhost:8000
Enter fullscreen mode Exit fullscreen mode

Now open the page in exactly two tabs.

Something important:

BroadcastChannel only communicates between pages on the same origin.

So this lab is intentionally limited to two tabs on the same browser/computer.

It is a tiny replacement for a real signaling server.

In a real application, your signaling layer could be WebSocket, HTTP, or another transport.


4. Open Two Tabs

In the first tab:

Caller

In the second tab:

Receiver

Once the Caller is selected, it creates an SDP offer.

The Receiver accepts that offer and creates an answer.

Meanwhile, both browsers are gathering ICE candidates.

You'll start seeing things like:

host | udp | ...
srflx | udp | ...
Enter fullscreen mode Exit fullscreen mode

depending on what your browser and network expose.

This is where Part 1 comes back.

We already found the addresses.

Now we're going to find out which of them can actually be used.


5. Watch ICE Work

Look at the status section.

You'll see:

ICE gathering: gathering
ICE connection: checking
Connection: connecting
Enter fullscreen mode Exit fullscreen mode

and eventually, if everything works:

ICE gathering: complete
ICE connection: connected
Connection: connected
Enter fullscreen mode Exit fullscreen mode

The important transition is:

checking
   ↓
connected
Enter fullscreen mode Exit fullscreen mode

That means ICE has found a viable way for the two peers to communicate.


6. Look at the Candidates

Scroll down to ICE Candidates.

You may see several candidates.

For example:

host | udp | ...
srflx | udp | ...
Enter fullscreen mode Exit fullscreen mode

These represent different possibilities.

A host candidate is associated with a local interface.

A srflx candidate represents an address discovered through STUN.

You might also see .local hostnames instead of raw local IP addresses. Modern browsers can use mDNS to avoid exposing local IP addresses directly to web pages.

The important idea is:

A candidate is a possibility, not a connection.

We haven't selected a path yet.


7. Prove the Path

If the connection succeeds, you'll also see:

DataChannel opened.
Message received: Hello from the other side!
Enter fullscreen mode Exit fullscreen mode

This is the fun part.

Something actually crossed the connection.

The browser didn't just discover an address.

It found a working path and opened a WebRTC DataChannel through it.

But let's inspect what happened underneath.


8. Inspect the Candidate Pairs

Click:

Inspect Candidate Pairs

The browser's statistics API will show candidate-pair reports.

You may see something similar to:

state: succeeded
localCandidateId: ...
remoteCandidateId: ...
currentRoundTripTime: ...
bytesSent: ...
bytesReceived: ...
Enter fullscreen mode Exit fullscreen mode

The important field is:

state: succeeded
Enter fullscreen mode Exit fullscreen mode

A candidate pair is made by combining:

Local Candidate
       +
Remote Candidate
       =
Candidate Pair
Enter fullscreen mode Exit fullscreen mode

The browser can have multiple combinations.

ICE checks those possibilities and determines which ones are usable.

The statistics output here shows the candidate-pair reports the browser currently exposes.

It is useful for seeing what ICE knows about the connection.


What's Happening Underneath?

Let's zoom out.

Imagine Browser A has:

A1 → host candidate
A2 → server-reflexive candidate
Enter fullscreen mode Exit fullscreen mode

Browser B has:

B1 → host candidate
B2 → server-reflexive candidate
Enter fullscreen mode Exit fullscreen mode

ICE can form combinations:

A1 ↔ B1
A1 ↔ B2
A2 ↔ B1
A2 ↔ B2
Enter fullscreen mode Exit fullscreen mode

Those are candidate pairs.

Now the browser starts asking:

Can I actually reach you using this pair?

ICE connectivity checks use STUN transactions to test those possible paths.

Conceptually:

Browser A
   │
   │ STUN connectivity check
   ▼
Browser B
   │
   │ response
   ▼
Browser A
Enter fullscreen mode Exit fullscreen mode

If the check succeeds, that candidate pair is viable.

If it fails, ICE can try another pair.

So the process looks roughly like this:

Candidates
    ↓
Candidate Pairs
    ↓
Connectivity Checks
    ↓
Working Pair
    ↓
WebRTC Connection
Enter fullscreen mode Exit fullscreen mode

That's the missing piece from Part 1.

We had addresses.

Now we have a path.


What About TURN?

So far, we've been talking about finding a direct path.

But what if the browsers can't reach each other directly?

That's where TURN enters the story.

Instead of:

Browser A ─────────────── Browser B
Enter fullscreen mode Exit fullscreen mode

we may need:

Browser A
     │
     ▼
 TURN Server
     │
     ▼
Browser B
Enter fullscreen mode Exit fullscreen mode

The TURN server acts as a relay.

The browsers still use ICE to discover and test possible paths, but a relay candidate gives them another option when direct connectivity isn't possible.

This is why WebRTC doesn't simply say:

"Here's my IP address. Connect to me."

There can be several possible paths.

Some work.

Some don't.

And sometimes you need a relay.


Try Something Different

Now reload both tabs and run the experiment again.

Watch the logs.

Pay attention to the order:

Candidate gathered
Candidate exchanged
Remote candidate added
ICE connection state: checking
ICE connection state: connected
DataChannel opened
Enter fullscreen mode Exit fullscreen mode

The exact order can vary.

That's part of the lesson.

Networking isn't a neat sequence where every message arrives exactly when you expect it.

Candidates can arrive before the remote description is ready.

That's why the example keeps them temporarily:

let pendingCandidates = [];
Enter fullscreen mode Exit fullscreen mode

and later adds them:

await pc.addIceCandidate(candidate);
Enter fullscreen mode Exit fullscreen mode

after the remote description has been installed.

This is one of those small details that becomes very important when you build a real WebRTC application.


The Important Mental Model

At this point, keep these three things separate:

1. Candidate

"Here's one address I might be reachable through."

2. Candidate Pair

"Let's combine one of my candidates with one of yours."

3. Connectivity Check

"Let's see if this particular combination actually works."

That gives us:

Address
   ↓
Candidate
   ↓
Candidate Pair
   ↓
Connectivity Check
   ↓
Working Path
Enter fullscreen mode Exit fullscreen mode

And that is what ICE is doing for us.


What Did We Actually Learn?

In Part 1, we discovered that a browser can have multiple network addresses.

But having an address doesn't mean another browser can reach you through it.

In this lab, we went one step further.

We:

  • Gathered ICE candidates.
  • Exchanged candidates between two peers.
  • Formed candidate pairs.
  • Ran ICE connectivity checks.
  • Observed the ICE connection state.
  • Opened a DataChannel.
  • Inspected candidate-pair statistics.
  • Saw where TURN fits when direct connectivity isn't possible.

The important distinction is:

A candidate tells you where you might be reachable. ICE determines whether a path actually works.


The Road Is Ready

We've solved one problem.

The browsers found a way to reach each other.

But now we have a new question.

Now that we have a path, what can we actually send through it?

That's where we'll go next.

Part 3 — Your Browser Found a Path. Now Let's Send Something Through It.

We'll finally move from:

"Can these browsers reach each other?"
Enter fullscreen mode Exit fullscreen mode

to:

"Can they actually exchange data?"
Enter fullscreen mode Exit fullscreen mode

And that's where WebRTC starts to feel less like networking theory and more like something alive.


References

  1. RFC 8445 — Interactive Connectivity Establishment (ICE)
  2. RFC 8489 — Session Traversal Utilities for NAT (STUN)
  3. MDN — WebRTC connectivity
  4. MDN — RTCPeerConnection.icecandidate
  5. MDN — RTCPeerConnection.addIceCandidate()
  6. MDN — RTCPeerConnection.getStats()
  7. MDN — RTCIceCandidatePairStats
  8. MDN — Signaling and video calling
  9. MDN — WebRTC data channels

Top comments (0)