DEV Community

Cover image for The Call That Hasn't Started Yet
Rittly Labs
Rittly Labs

Posted on

The Call That Hasn't Started Yet

A technical companion to the WebRTC connection journey: signaling,
SDP, Offer/Answer, ICE, STUN, TURN, Trickle ICE, and connectivity
debugging.

What this article adds

The Medium article follows a WebRTC connection as a story. This
companion gets closer to the implementation.

We will build a small two-browser WebRTC application with:

  • a WebSocket signaling server
  • SDP Offer/Answer negotiation
  • trickled ICE candidates
  • a WebRTC DataChannel
  • connection-state logging
  • no framework and no build step

The key distinction is simple:

Signaling carries the information needed to establish the
connection. WebRTC carries the actual peer data once connectivity is
established.


1. Architecture

             Signaling
        WebSocket Server
          /           \
         /             \
        ▼               ▼
   Alice Browser    Bob Browser
        │               │
        │   WebRTC      │
        └───────────────┘
          Peer Connection
Enter fullscreen mode Exit fullscreen mode

The signaling server forwards:

  • SDP offers
  • SDP answers
  • ICE candidates
  • room information

It does not automatically become the media or DataChannel path.


2. Project

webrtc-demo/
├── server.js
└── public/
    └── index.html
Enter fullscreen mode Exit fullscreen mode

Create it:

mkdir webrtc-demo
cd webrtc-demo
npm init -y
npm install ws
mkdir public
Enter fullscreen mode Exit fullscreen mode

3. Signaling server

Create server.js:

const http = require("http");
const fs = require("fs");
const path = require("path");
const WebSocket = require("ws");

const PORT = 8000;

const server = http.createServer((req, res) => {
  const file = path.join(
    __dirname,
    "public",
    req.url === "/" ? "index.html" : req.url
  );

  fs.readFile(file, (error, data) => {
    if (error) {
      res.writeHead(404);
      res.end("Not found");
      return;
    }

    res.writeHead(200, {
      "Content-Type": "text/html"
    });

    res.end(data);
  });
});

const wss = new WebSocket.Server({ server });
const rooms = new Map();

wss.on("connection", (socket) => {
  let roomId = null;

  socket.on("message", (rawMessage) => {
    let message;

    try {
      message = JSON.parse(rawMessage);
    } catch {
      return;
    }

    if (message.type === "join") {
      roomId = message.room;

      if (!rooms.has(roomId)) {
        rooms.set(roomId, new Set());
      }

      const room = rooms.get(roomId);

      if (room.size >= 2) {
        socket.send(JSON.stringify({
          type: "error",
          message: "Room is full"
        }));
        return;
      }

      room.add(socket);

      socket.send(JSON.stringify({
        type: "joined",
        initiator: room.size === 1
      }));

      // The first peer creates its Offer only after a second peer is present.
      if (room.size === 2) {
        for (const peer of room) {
          if (peer !== socket && peer.readyState === WebSocket.OPEN) {
            peer.send(JSON.stringify({
              type: "peer-joined"
            }));
          }
        }
      }

      return;
    }

    if (!roomId || !rooms.has(roomId)) {
      return;
    }

    for (const peer of rooms.get(roomId)) {
      if (peer !== socket && peer.readyState === WebSocket.OPEN) {
        peer.send(JSON.stringify(message));
      }
    }
  });

  socket.on("close", () => {
    if (!roomId || !rooms.has(roomId)) {
      return;
    }

    const room = rooms.get(roomId);
    room.delete(socket);

    if (room.size === 0) {
      rooms.delete(roomId);
    }
  });
});

server.listen(PORT, () => {
  console.log(`WebRTC demo running at http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Run it:

node server.js
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8000 in two browser tabs and join the same room.


4. Browser application

Create public/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 Demo</title>
  <style>
    body {
      font-family: system-ui, sans-serif;
      max-width: 800px;
      margin: 40px auto;
      padding: 20px;
    }

    input, button {
      padding: 8px 12px;
      margin: 4px;
    }

    #log {
      background: #111;
      color: #eee;
      padding: 16px;
      min-height: 300px;
      white-space: pre-wrap;
    }
  </style>
</head>

<body>

  <h1>WebRTC Demo</h1>

  <input id="room" value="demo-room">
  <button id="join" disabled>Join Room</button>
  <button id="send" disabled>Send Message</button>

  <pre id="log"></pre>

  <script>
    const logElement = document.getElementById("log");

    function log(message) {
      logElement.textContent += message + "\n";
    }

    const roomInput = document.getElementById("room");
    const joinButton = document.getElementById("join");
    const sendButton = document.getElementById("send");

    const socket = new WebSocket(
      `ws://${window.location.host}`
    );

    socket.onopen = () => {
      log("Signaling connected");
      joinButton.disabled = false;
    };

    socket.onerror = () => {
      log("Signaling connection failed");
    };

    const pc = new RTCPeerConnection({
      iceServers: []
    });

    let dataChannel = null;
    const pendingCandidates = [];

    async function addRemoteCandidate(candidate) {
      if (!pc.remoteDescription) {
        pendingCandidates.push(candidate);
        log("Remote ICE candidate queued");
        return;
      }

      await pc.addIceCandidate(candidate);
      log("Remote ICE candidate added");
    }

    async function flushPendingCandidates() {
      while (pendingCandidates.length > 0) {
        await pc.addIceCandidate(pendingCandidates.shift());
        log("Queued remote ICE candidate added");
      }
    }

    function createDataChannel() {
      dataChannel = pc.createDataChannel("demo");

      dataChannel.onopen = () => {
        log("Data channel: open");
        sendButton.disabled = false;
      };

      dataChannel.onclose = () => {
        log("Data channel: closed");
        sendButton.disabled = true;
      };

      dataChannel.onmessage = (event) => {
        log("Received: " + event.data);
      };
    }

    pc.ondatachannel = (event) => {
      dataChannel = event.channel;

      dataChannel.onopen = () => {
        log("Data channel: open");
        sendButton.disabled = false;
      };

      dataChannel.onclose = () => {
        log("Data channel: closed");
        sendButton.disabled = true;
      };

      dataChannel.onmessage = (event) => {
        log("Received: " + event.data);
      };
    };

    // Trickle ICE: forward candidates as they are discovered.
    pc.onicecandidate = (event) => {
      if (event.candidate) {
        socket.send(JSON.stringify({
          type: "ice-candidate",
          candidate: event.candidate
        }));

        log("ICE candidate sent");
      } else {
        log("ICE gathering complete");
      }
    };

    pc.oniceconnectionstatechange = () => {
      log("ICE state: " + pc.iceConnectionState);
    };

    pc.onconnectionstatechange = () => {
      log("Connection state: " + pc.connectionState);
    };

    socket.onmessage = async (event) => {
      const message = JSON.parse(event.data);

      try {
        if (message.type === "joined") {
          log("Joined room");

          if (message.initiator) {
            log("You are the initiator");
          }

          return;
        }

        if (message.type === "peer-joined") {
          log("Peer joined room");

          createDataChannel();

          const offer = await pc.createOffer();
          await pc.setLocalDescription(offer);

          socket.send(JSON.stringify({
            type: "offer",
            description: pc.localDescription
          }));

          log("SDP Offer sent");
          return;
        }

        if (message.type === "offer") {
          log("SDP Offer received");

          await pc.setRemoteDescription(
            message.description
          );

          await flushPendingCandidates();

          const answer = await pc.createAnswer();
          await pc.setLocalDescription(answer);

          socket.send(JSON.stringify({
            type: "answer",
            description: pc.localDescription
          }));

          log("SDP Answer sent");
          return;
        }

        if (message.type === "answer") {
          log("SDP Answer received");

          await pc.setRemoteDescription(
            message.description
          );

          await flushPendingCandidates();

          log("Remote description set");
          return;
        }

        if (message.type === "ice-candidate") {
          await addRemoteCandidate(message.candidate);
          return;
        }

        if (message.type === "error") {
          log("Error: " + message.message);
        }
      } catch (error) {
        log("WebRTC error: " + error.message);
      }
    };

    joinButton.onclick = () => {
      if (socket.readyState !== WebSocket.OPEN) {
        log("Signaling is not connected yet");
        return;
      }

      const room = roomInput.value.trim();

      if (!room) {
        log("Enter a room name");
        return;
      }

      socket.send(JSON.stringify({
        type: "join",
        room
      }));

      log("Joining room: " + room);
      joinButton.disabled = true;
      roomInput.disabled = true;
    };

    sendButton.onclick = () => {
      if (!dataChannel || dataChannel.readyState !== "open") {
        return;
      }

      const message = "Hello from WebRTC!";

      dataChannel.send(message);
      log("Sent: " + message);
    };
  </script>

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

5. What happens?

Open two tabs:

Tab A → demo-room
Tab B → demo-room
Enter fullscreen mode Exit fullscreen mode

The first peer becomes the initiator and waits for the second peer to
join.

The second peer becomes the receiver. When it joins, the server tells
the initiator that a peer is ready; only then does the initiator create
and send the Offer.

The sequence is:

Alice                    Signaling                    Bob
  │                         │                          │
  │──── join(room) ────────►│                          │
  │                         │◄──── join(room) ─────────│
  │◄──── peer-joined ───────│                          │
  │                         │                          │
  │ createOffer()           │                          │
  │                         │                          │
  │──── Offer ─────────────►│──── Offer ──────────────►│
  │                         │                          │
  │                         │             setRemoteDescription()
  │                         │             createAnswer()
  │                         │                          │
  │◄──── Answer ────────────│◄──── Answer ─────────────│
  │                         │                          │
  │──── ICE candidate ─────►│──── ICE candidate ──────►│
  │◄──── ICE candidate ─────│◄──── ICE candidate ──────│
  │                         │                          │
  │        ICE connectivity checks                       │
  │                         │                          │
  │════════════ WebRTC connection established ══════════│
  │                         │                          │
  │──────── DataChannel message ───────────────────────►│
Enter fullscreen mode Exit fullscreen mode

6. Signaling is application-defined

WebRTC does not require your application to use WebSocket.

The application decides how peers exchange:

  • SDP offers
  • SDP answers
  • ICE candidates
  • room information
  • authentication metadata

The transport could be WebSocket, HTTP, or another application-level
mechanism.

In this demo, WebSocket is simply the signaling transport.

The important separation is:

Application signaling
        │
        ├── Offer
        ├── Answer
        └── ICE candidates
                │
                ▼
         RTCPeerConnection
                │
                ▼
       WebRTC connectivity
Enter fullscreen mode Exit fullscreen mode

7. Creating the Offer

The initiator calls:

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
Enter fullscreen mode Exit fullscreen mode

createOffer() generates an SDP description representing the proposed
session.

The SDP can describe session information such as:

  • media sections
  • codecs
  • media directions
  • transport information
  • ICE-related information

The exact SDP is browser- and configuration-dependent.

Treat SDP as a protocol artifact. In normal application code, you should
let the browser generate it rather than manually constructing it.


8. Why setLocalDescription() matters

These operations have different responsibilities:

const offer = await pc.createOffer();

await pc.setLocalDescription(offer);
Enter fullscreen mode Exit fullscreen mode

createOffer() creates the proposed description.

setLocalDescription() applies that description to the local peer
connection.

After the local description is set, ICE gathering can begin.

That is why the application sends:

pc.localDescription
Enter fullscreen mode Exit fullscreen mode

through signaling.


9. Receiving the Offer and creating the Answer

Bob receives Alice's Offer:

await pc.setRemoteDescription(
  message.description
);
Enter fullscreen mode Exit fullscreen mode

Bob then creates an Answer:

const answer = await pc.createAnswer();

await pc.setLocalDescription(answer);
Enter fullscreen mode Exit fullscreen mode

The Answer goes back through signaling:

socket.send(JSON.stringify({
  type: "answer",
  description: pc.localDescription
}));
Enter fullscreen mode Exit fullscreen mode

Alice applies it:

await pc.setRemoteDescription(
  message.description
);
Enter fullscreen mode Exit fullscreen mode

The Offer/Answer exchange establishes the negotiated session
configuration.

It does not, by itself, prove that the peers have a working network
path.


10. ICE candidates

An ICE candidate represents a possible way to reach the remote peer.

A simplified model is:

Host candidate
    └── local interface

Server-reflexive candidate
    └── address observed through STUN

Relay candidate
    └── address provided by TURN
Enter fullscreen mode Exit fullscreen mode

Candidates are exposed through:

pc.onicecandidate = (event) => {
  if (event.candidate) {
    // Send candidate through signaling.
  }
};
Enter fullscreen mode Exit fullscreen mode

The remote peer supplies received candidates to its ICE agent:

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

11. Trickle ICE

This demo sends candidates as soon as the browser discovers them.

That is Trickle ICE:

Candidate discovered
       ↓
Send candidate
       ↓
Remote peer receives it
       ↓
addIceCandidate()
       ↓
Connectivity checks
Enter fullscreen mode Exit fullscreen mode

The application does not need to wait for all candidates before
beginning candidate exchange.

This can reduce connection setup time.


12. ICE connectivity checks

After candidates have been exchanged, ICE evaluates candidate pairs:

Alice candidates        Bob candidates

      A1 ───────────────── B1
      A1 ───────────────── B2
      A2 ───────────────── B1
      A2 ───────────────── B2
      A3 ───────────────── B1
      A3 ───────────────── B2
Enter fullscreen mode Exit fullscreen mode

ICE performs connectivity checks and works toward selecting a usable
candidate pair.

The signaling server helped exchange the candidates.

The ICE agent determines whether the candidate pair actually works.


13. STUN and TURN

The demo intentionally uses:

const pc = new RTCPeerConnection({
  iceServers: []
});
Enter fullscreen mode Exit fullscreen mode

This keeps the local experiment simple.

Real Internet connectivity is more complicated because peers can be
behind NATs and firewalls.

STUN can help discover the public-facing address observed by a STUN
server.

TURN can provide a relay when a direct path cannot be established.

Conceptually:

Direct:

Alice ───────────────────── Bob


Relay:

Alice ───────► TURN ───────► Bob
Enter fullscreen mode Exit fullscreen mode

A real deployment might configure:

const pc = new RTCPeerConnection({
  iceServers: [
    {
      urls: "stun:stun.example.com:3478"
    },
    {
      urls: "turn:turn.example.com:3478",
      username: "temporary-user",
      credential: "temporary-password"
    }
  ]
});
Enter fullscreen mode Exit fullscreen mode

Do not put permanent TURN credentials in production frontend code. Use
an appropriate authenticated mechanism to issue temporary credentials.


14. Watching the state machines

ICE state:

pc.oniceconnectionstatechange = () => {
  console.log(pc.iceConnectionState);
};
Enter fullscreen mode Exit fullscreen mode

Overall connection state:

pc.onconnectionstatechange = () => {
  console.log(pc.connectionState);
};
Enter fullscreen mode Exit fullscreen mode

You may see states such as:

new
checking
connected
completed
failed
disconnected
closed
Enter fullscreen mode Exit fullscreen mode

The exact transitions depend on the browser and network.

For a successful connection, the overall connection should eventually
reach:

connected
Enter fullscreen mode Exit fullscreen mode

15. Why use a DataChannel?

The demo uses a DataChannel instead of immediately introducing camera
and microphone capture.

The initiator creates one:

const dataChannel = pc.createDataChannel("demo");
Enter fullscreen mode Exit fullscreen mode

Once open:

dataChannel.onopen = () => {
  console.log("Data channel is open");
};
Enter fullscreen mode Exit fullscreen mode

We can send:

dataChannel.send("Hello from WebRTC!");
Enter fullscreen mode Exit fullscreen mode

The remote peer receives:

dataChannel.onmessage = (event) => {
  console.log(event.data);
};
Enter fullscreen mode Exit fullscreen mode

This proves that the WebRTC connection is carrying application data.

The same peer connection can also carry media tracks.


16. Adding audio and video later

Once the basic connection is understood, media can be added:

const stream =
  await navigator.mediaDevices.getUserMedia({
    audio: true,
    video: true
  });

stream.getTracks().forEach((track) => {
  pc.addTrack(track, stream);
});
Enter fullscreen mode Exit fullscreen mode

The media tracks then participate in the Offer/Answer negotiation.

The architecture remains:

             RTCPeerConnection
                    │
          ┌─────────┴─────────┐
          │                   │
     DataChannel          Media Tracks
          │                   │
          ▼                   ▼
       Data            Audio / Video
Enter fullscreen mode Exit fullscreen mode

17. Debugging a failed connection

When a connection fails, identify which stage failed.

Did signaling work?
        ↓
Did the Offer arrive?
        ↓
Did the Answer arrive?
        ↓
Are ICE candidates being exchanged?
        ↓
Did ICE reach "checking"?
        ↓
Did ICE find a working candidate pair?
        ↓
Did the peer connection reach "connected"?
        ↓
Is the DataChannel or media flowing?
Enter fullscreen mode Exit fullscreen mode

Useful diagnostics:

pc.onicegatheringstatechange = () => {
  console.log(
    "ICE gathering:",
    pc.iceGatheringState
  );
};

pc.oniceconnectionstatechange = () => {
  console.log(
    "ICE connection:",
    pc.iceConnectionState
  );
};

pc.onconnectionstatechange = () => {
  console.log(
    "Connection:",
    pc.connectionState
  );
};

pc.onsignalingstatechange = () => {
  console.log(
    "Signaling:",
    pc.signalingState
  );
};
Enter fullscreen mode Exit fullscreen mode

Once a connection exists, getStats() becomes useful:

const stats = await pc.getStats();

stats.forEach((report) => {
  console.log(report.type, report);
});
Enter fullscreen mode Exit fullscreen mode

Statistics can expose information about candidate pairs, packets, bytes,
round-trip time, codecs, RTP, and DataChannels.


18. A common mistake: confusing signaling with connectivity

This is one of the most important debugging distinctions.

A successful signaling exchange:

Offer sent
Answer received
Enter fullscreen mode Exit fullscreen mode

does not mean:

WebRTC connected
Enter fullscreen mode Exit fullscreen mode

The browsers still need to exchange candidates and perform ICE
connectivity checks.

Think of the process as two separate problems:

Problem 1
"How do the browsers exchange setup information?"
              │
              ▼
          Signaling


Problem 2
"Can the browsers actually reach each other?"
              │
              ▼
              ICE
Enter fullscreen mode Exit fullscreen mode

Solving the first problem does not automatically solve the second.


19. Another common mistake: calling addIceCandidate() too early

Remote candidates should be applied only after the relevant remote
description has been set.

This ordering matters:

setRemoteDescription()
        ↓
addIceCandidate()
Enter fullscreen mode Exit fullscreen mode

A signaling layer should queue candidates that arrive before the remote
description is ready. This demo does that with pendingCandidates and
applies them after setRemoteDescription() completes.

The exact handling becomes especially important when messages can arrive
asynchronously or out of order.


20. Offer collisions and renegotiation

The demo assumes a simple call setup:

  • one initiator
  • one receiver
  • one Offer
  • one Answer

Real applications are more complicated.

Both peers can potentially attempt negotiation at the same time.

This creates an offer collision.

For production applications, the WebRTC community commonly uses the
perfect negotiation pattern to handle simultaneous offers and
renegotiation safely.

This becomes important when:

  • tracks are added or removed
  • transceivers change
  • screen sharing starts or stops
  • connections are renegotiated
  • both peers can initiate changes

The small demo intentionally avoids this complexity.


21. ICE restart

Network conditions can change after a connection has already been
established.

For example:

Wi-Fi
  ↓
network changes
  ↓
old path no longer works
Enter fullscreen mode Exit fullscreen mode

WebRTC supports ICE restart so the peers can gather new connectivity
information and attempt to establish a new path.

This is one of the reasons a production WebRTC application should not
treat connection establishment as a one-time event.

Connectivity can change during the lifetime of a call.


22. Production architecture

A production system usually adds more components around this core:

                     Application
                          │
               ┌──────────┴──────────┐
               │                     │
          Signaling                 Auth
               │                     │
               ▼                     │
        WebSocket / HTTP             │
               │                     │
        ┌──────┴──────┐              │
        ▼             ▼              │
     Browser A     Browser B ◄────────┘
        │             │
        └──────┬──────┘
               │
        RTCPeerConnection
               │
        ┌──────┴──────┐
        │             │
      STUN           TURN
        │             │
        └──────┬──────┘
               │
          Network path
Enter fullscreen mode Exit fullscreen mode

Production concerns include:

  • authentication
  • authorization
  • room lifecycle
  • reconnects
  • offer collisions
  • renegotiation
  • ICE restarts
  • TURN credential management
  • metrics
  • observability
  • rate limiting
  • abuse prevention
  • browser compatibility
  • failure handling

The demo deliberately leaves these out so the connection lifecycle
remains visible.


23. The complete lifecycle

Put everything together:

Create RTCPeerConnection
          ↓
Configure media / data
          ↓
Create SDP Offer
          ↓
setLocalDescription()
          ↓
ICE gathering
          ↓
Send Offer through signaling
          ↓
Remote setRemoteDescription()
          ↓
Create SDP Answer
          ↓
setLocalDescription()
          ↓
Send Answer through signaling
          ↓
Remote setRemoteDescription()
          ↓
Exchange ICE candidates
          ↓
ICE connectivity checks
          ↓
Select usable candidate pair
          ↓
WebRTC connection established
          ↓
DataChannel / Audio / Video
Enter fullscreen mode Exit fullscreen mode

This is the sequence hidden behind the application's Call button.


24. The mental model

After working through the implementation, the terminology becomes easier
to remember:

Signaling moves negotiation information between applications.

SDP describes the proposed session.

Offer proposes a session configuration.

Answer responds with the configuration accepted by the other peer.

ICE discovers and tests possible network paths.

ICE candidate describes one possible network endpoint.

STUN helps discover a server-reflexive network address.

TURN provides a relay when a direct path cannot be established.

Trickle ICE sends candidates as they are discovered.

The relationship is:

Signaling
    │
    ├── Offer
    ├── Answer
    └── ICE candidates
            │
            ▼
      RTCPeerConnection
            │
            ├── ICE connectivity
            ├── DataChannel
            └── Audio / Video
Enter fullscreen mode Exit fullscreen mode

25. Break the demo on purpose

Once the demo works, deliberately break one part.

For example, temporarily remove:

socket.send(JSON.stringify({
  type: "ice-candidate",
  candidate: event.candidate
}));
Enter fullscreen mode Exit fullscreen mode

The peers can still exchange:

Offer
Answer
Enter fullscreen mode Exit fullscreen mode

but they are no longer exchanging trickled ICE candidates.

Watch the connection state.

This is where WebRTC becomes much easier to understand.

Don't just read the terminology. Change something. Break something.
Watch what changes.


References

  1. WebRTC 1.0: Real-Time Communication Between Browsers W3C
  2. RFC 8445: Interactive Connectivity Establishment (ICE)
  3. RFC 3264: An Offer/Answer Model with the Session Description Protocol
  4. RFC 4566: SDP: Session Description Protocol
  5. RFC 8489: Session Traversal Utilities for NAT (STUN)
  6. RFC 8656: Traversal Using Relays around NAT (TURN)
  7. RFC 8838: Trickle ICE
  8. MDN: WebRTC API
  9. MDN: WebRTC Connectivity
  10. MDN: Signaling and video calling
  11. WebRTC Samples

Top comments (0)