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
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
Create it:
mkdir webrtc-demo
cd webrtc-demo
npm init -y
npm install ws
mkdir public
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}`);
});
Run it:
node server.js
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>
5. What happens?
Open two tabs:
Tab A → demo-room
Tab B → demo-room
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 ───────────────────────►│
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
7. Creating the Offer
The initiator calls:
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
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);
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
through signaling.
9. Receiving the Offer and creating the Answer
Bob receives Alice's Offer:
await pc.setRemoteDescription(
message.description
);
Bob then creates an Answer:
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
The Answer goes back through signaling:
socket.send(JSON.stringify({
type: "answer",
description: pc.localDescription
}));
Alice applies it:
await pc.setRemoteDescription(
message.description
);
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
Candidates are exposed through:
pc.onicecandidate = (event) => {
if (event.candidate) {
// Send candidate through signaling.
}
};
The remote peer supplies received candidates to its ICE agent:
await pc.addIceCandidate(
message.candidate
);
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
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
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: []
});
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
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"
}
]
});
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);
};
Overall connection state:
pc.onconnectionstatechange = () => {
console.log(pc.connectionState);
};
You may see states such as:
new
checking
connected
completed
failed
disconnected
closed
The exact transitions depend on the browser and network.
For a successful connection, the overall connection should eventually
reach:
connected
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");
Once open:
dataChannel.onopen = () => {
console.log("Data channel is open");
};
We can send:
dataChannel.send("Hello from WebRTC!");
The remote peer receives:
dataChannel.onmessage = (event) => {
console.log(event.data);
};
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);
});
The media tracks then participate in the Offer/Answer negotiation.
The architecture remains:
RTCPeerConnection
│
┌─────────┴─────────┐
│ │
DataChannel Media Tracks
│ │
▼ ▼
Data Audio / Video
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?
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
);
};
Once a connection exists, getStats() becomes useful:
const stats = await pc.getStats();
stats.forEach((report) => {
console.log(report.type, report);
});
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
does not mean:
WebRTC connected
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
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()
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
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
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
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
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
}));
The peers can still exchange:
Offer
Answer
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
- WebRTC 1.0: Real-Time Communication Between Browsers W3C
- RFC 8445: Interactive Connectivity Establishment (ICE)
- RFC 3264: An Offer/Answer Model with the Session Description Protocol
- RFC 4566: SDP: Session Description Protocol
- RFC 8489: Session Traversal Utilities for NAT (STUN)
- RFC 8656: Traversal Using Relays around NAT (TURN)
- RFC 8838: Trickle ICE
- MDN: WebRTC API
- MDN: WebRTC Connectivity
- MDN: Signaling and video calling
- WebRTC Samples
Top comments (0)