WebRTC Signaling and Data Channels From Scratch: STUN, TURN, ICE, SDP, and Peer-to-Peer Communication
WebRTC Signaling and Data Channels From Scratch: STUN, TURN, ICE, SDP, and Peer-to-Peer Communication
Why WebRTC Matters
WebRTC enables real-time peer-to-peer communication directly between browsers — no server relay for data. Video calls, screen sharing, file transfer, multiplayer games, and collaborative editing all use WebRTC under the hood. But the signaling process that establishes connections is notoriously confusing: STUN, TURN, ICE candidates, SDP offers and answers — each layer solves a specific NAT traversal or negotiation problem.
This post implements a complete WebRTC signaling system: a signaling server that brokers connections, ICE candidate gathering, SDP offer/answer exchange, and reliable data channels for peer-to-peer messaging. We build a working collaborative text editor on top.
Architecture
WebRTC CONNECTION ESTABLISHMENT:
═══════════════════════════════
The Paradox: To send data to a peer, you need their IP.
But both peers are behind NATs — they don't know their public IP/port.
Solution: A 3-step dance (signaling → ICE → connection):
┌────────┐ ┌────────┐
│ Peer A │ │ Peer B │
│ (NAT) │ │ (NAT) │
└───┬────┘ └───┬────┘
│ │
│ 1. SDP OFFER (via signaling server) │
│──────────────────────────────────────→│
│ │
│ 2. SDP ANSWER (via signaling server) │
│←──────────────────────────────────────│
│ │
│ 3. ICE CANDIDATES (via signaling) │
│←─────────────────────────────────────→│
│ │
│ STUN: "What's my public IP:port?" │
│──→ STUN Server ──→ "203.0.113.5:4820" │
│ │
│ 4. DIRECT P2P CONNECTION │
│◄═════════════════════════════════════►│
│ (UDP, no server in the middle) │
│ │
If direct fails (symmetric NAT), TURN server relays traffic.
~85% of connections succeed with STUN alone.
Signaling Server
// The signaling server is the ONLY part that requires a central server.
// It passes SDP offers/answers and ICE candidates between peers.
// After connection is established, the server is no longer needed.
interface SignalingMessage {
type: "offer" | "answer" | "ice-candidate" | "join" | "leave" | "error";
from: string;
to?: string;
roomId: string;
payload: any;
}
type MessageHandler = (message: SignalingMessage) => void;
class SignalingServer {
private rooms = new Map<string, Map<string, MessageHandler>>();
private peerRooms = new Map<string, string>();
/**
* Simulates the signaling server.
* In production, this would be a WebSocket server.
*/
join(roomId: string, peerId: string, handler: MessageHandler): () => void {
let room = this.rooms.get(roomId);
if (!room) {
room = new Map();
this.rooms.set(roomId, room);
}
room.set(peerId, handler);
this.peerRooms.set(peerId, roomId);
// Notify existing peers:
for (const [existingPeerId, existingHandler] of room) {
if (existingPeerId !== peerId) {
existingHandler({
type: "join",
from: peerId,
roomId,
payload: { peerId },
});
}
}
// Return leave function:
return () => {
room!.delete(peerId);
this.peerRooms.delete(peerId);
for (const [_, h] of room!) {
h({
type: "leave",
from: peerId,
roomId,
payload: { peerId },
});
}
if (room!.size === 0) {
this.rooms.delete(roomId);
}
};
}
send(message: SignalingMessage): void {
const room = this.rooms.get(message.roomId);
if (!room) return;
if (message.to) {
// Direct message to specific peer:
const handler = room.get(message.to);
if (handler) handler(message);
} else {
// Broadcast to room (except sender):
for (const [peerId, handler] of room) {
if (peerId !== message.from) {
handler(message);
}
}
}
}
getPeersInRoom(roomId: string): string[] {
const room = this.rooms.get(roomId);
return room ? Array.from(room.keys()) : [];
}
}
ICE Candidate Gathering
ICE (Interactive Connectivity Establishment):
═════════════════════════════════════════════
ICE gathers multiple network paths and tests which one works.
Candidate types (in preference order):
1. HOST: Local IP address (works on same network)
192.168.1.5:54321 (UDP)
2. SERVER REFLEXIVE (srflx): Public IP discovered via STUN
203.0.113.5:4820 (UDP)
STUN server just echoes back "your public IP is X"
3. RELAY: Traffic relayed through TURN server
198.51.100.1:3478 (UDP/TCP)
Used when direct P2P fails (symmetric NAT)
Adds ~50ms latency, server bandwidth cost
┌──────────┐ "What's my IP?" ┌──────────┐
│ Peer A │ ─────────────────→│ STUN │
│ 192.168. │ │ Server │
│ 1.5 │ ←─────────────────│ │
└──────────┘ "203.0.113.5" └──────────┘
ICE pairs each local candidate with each remote candidate
and runs connectivity checks. First working pair wins.
interface IceCandidate {
type: "host" | "srflx" | "relay";
ip: string;
port: number;
protocol: "udp" | "tcp";
priority: number;
foundation: string;
component: number;
}
interface StunServer {
urls: string;
}
interface TurnServer {
urls: string;
username: string;
credential: string;
}
interface IceConfig {
stunServers: StunServer[];
turnServers: TurnServer[];
}
class IceAgent {
private localCandidates: IceCandidate[] = [];
private remoteCandidates: IceCandidate[] = [];
private candidatePairs: Array<{
local: IceCandidate;
remote: IceCandidate;
state: "waiting" | "in-progress" | "succeeded" | "failed";
priority: number;
}> = [];
private config: IceConfig;
private onCandidate?: (candidate: IceCandidate) => void;
private onConnected?: (pair: { local: IceCandidate; remote: IceCandidate }) => void;
constructor(config: IceConfig) {
this.config = config;
}
/**
* Gather local ICE candidates.
* In a real implementation, this queries network interfaces
* and STUN servers.
*/
async gatherCandidates(): Promise<IceCandidate[]> {
this.localCandidates = [];
// 1. HOST candidates (local interfaces):
const hostCandidate: IceCandidate = {
type: "host",
ip: "192.168.1.5",
port: this.randomPort(),
protocol: "udp",
priority: this.calcPriority("host", 0),
foundation: "host-udp",
component: 1, // RTP
};
this.addLocalCandidate(hostCandidate);
// 2. SRFLX candidates (STUN):
for (const stun of this.config.stunServers) {
try {
const reflexive = await this.stunBinding(stun);
this.addLocalCandidate(reflexive);
} catch (e) {
// STUN failed — continue with other candidates
}
}
// 3. RELAY candidates (TURN):
for (const turn of this.config.turnServers) {
try {
const relay = await this.turnAllocate(turn);
this.addLocalCandidate(relay);
} catch (e) {
// TURN failed
}
}
return this.localCandidates;
}
private addLocalCandidate(candidate: IceCandidate): void {
this.localCandidates.push(candidate);
this.onCandidate?.(candidate);
// Create pairs with all known remote candidates:
for (const remote of this.remoteCandidates) {
this.addCandidatePair(candidate, remote);
}
}
addRemoteCandidate(candidate: IceCandidate): void {
this.remoteCandidates.push(candidate);
// Pair with all local candidates:
for (const local of this.localCandidates) {
this.addCandidatePair(local, candidate);
}
}
private addCandidatePair(
local: IceCandidate,
remote: IceCandidate
): void {
// Priority: prefer host-to-host, then srflx, then relay
const priority = Math.min(local.priority, remote.priority);
this.candidatePairs.push({
local,
remote,
state: "waiting",
priority,
});
// Sort by priority (highest first):
this.candidatePairs.sort((a, b) => b.priority - a.priority);
}
/**
* Run connectivity checks on candidate pairs.
* Uses STUN binding requests over each candidate pair.
*/
async checkConnectivity(): Promise<boolean> {
for (const pair of this.candidatePairs) {
if (pair.state !== "waiting") continue;
pair.state = "in-progress";
try {
const success = await this.connectivityCheck(pair.local, pair.remote);
if (success) {
pair.state = "succeeded";
this.onConnected?.({ local: pair.local, remote: pair.remote });
return true;
}
} catch (e) {
pair.state = "failed";
}
}
return false;
}
private async stunBinding(server: StunServer): Promise<IceCandidate> {
// Simulated STUN binding — returns reflexive candidate:
return {
type: "srflx",
ip: "203.0.113." + Math.floor(Math.random() * 255),
port: this.randomPort(),
protocol: "udp",
priority: this.calcPriority("srflx", 0),
foundation: "srflx-udp",
component: 1,
};
}
private async turnAllocate(server: TurnServer): Promise<IceCandidate> {
return {
type: "relay",
ip: "198.51.100." + Math.floor(Math.random() * 255),
port: this.randomPort(),
protocol: "udp",
priority: this.calcPriority("relay", 0),
foundation: "relay-udp",
component: 1,
};
}
private async connectivityCheck(
local: IceCandidate,
remote: IceCandidate
): Promise<boolean> {
// Simulated connectivity check:
// host-to-host succeeds ~95% on same network
// srflx-to-srflx succeeds ~85% across NATs
// relay always succeeds (but adds latency)
if (local.type === "relay" || remote.type === "relay") return true;
return Math.random() < 0.85;
}
/**
* ICE candidate priority formula (RFC 5245):
* priority = (2^24) × typePreference + (2^8) × localPreference + componentId
*/
private calcPriority(type: string, localPref: number): number {
const typePrefs: Record<string, number> = {
host: 126,
srflx: 100,
relay: 0,
};
const typePref = typePrefs[type] ?? 0;
return (1 << 24) * typePref + (1 << 8) * localPref + 1;
}
private randomPort(): number {
return 49152 + Math.floor(Math.random() * 16383);
}
onCandidateGathered(handler: (c: IceCandidate) => void): void {
this.onCandidate = handler;
}
onConnectionEstablished(
handler: (pair: { local: IceCandidate; remote: IceCandidate }) => void
): void {
this.onConnected = handler;
}
}
SDP (Session Description Protocol)
/**
* SDP describes the media capabilities and transport of a peer.
* The "offer" says "here's what I support."
* The "answer" says "here's what I support too — let's use this."
*/
interface SessionDescription {
type: "offer" | "answer";
sdp: string; // Actual SDP text
}
interface MediaDescription {
type: "application"; // For data channels
protocol: "DTLS/SCTP";
port: number;
iceUfrag: string; // ICE credentials
icePwd: string;
fingerprint: string; // DTLS certificate fingerprint
setup: "actpass" | "active" | "passive";
sctpPort: number;
maxMessageSize: number;
}
class SdpBuilder {
/**
* Build an SDP offer for a data channel connection.
*
* Real SDP is a text format with lines like:
* v=0
* o=- 123456 2 IN IP4 127.0.0.1
* s=-
* t=0 0
* m=application 9 DTLS/SCTP webrtc-datachannel
* a=ice-ufrag:abc123
* a=ice-pwd:secret
* a=fingerprint:sha-256 AB:CD:...
* a=setup:actpass
*/
static createOffer(media: MediaDescription): SessionDescription {
const ufrag = this.randomString(8);
const pwd = this.randomString(24);
const fingerprint = this.randomFingerprint();
const sdp = [
"v=0",
`o=- ${Date.now()} 2 IN IP4 127.0.0.1`,
"s=-",
"t=0 0",
`m=${media.type} 9 ${media.protocol} webrtc-datachannel`,
"c=IN IP4 0.0.0.0",
`a=ice-ufrag:${ufrag}`,
`a=ice-pwd:${pwd}`,
`a=fingerprint:sha-256 ${fingerprint}`,
`a=setup:actpass`,
`a=sctp-port:${media.sctpPort}`,
`a=max-message-size:${media.maxMessageSize}`,
].join("\r\n") + "\r\n";
return { type: "offer", sdp };
}
static createAnswer(
offer: SessionDescription,
media: MediaDescription
): SessionDescription {
const ufrag = this.randomString(8);
const pwd = this.randomString(24);
const fingerprint = this.randomFingerprint();
const sdp = [
"v=0",
`o=- ${Date.now()} 2 IN IP4 127.0.0.1`,
"s=-",
"t=0 0",
`m=${media.type} 9 ${media.protocol} webrtc-datachannel`,
"c=IN IP4 0.0.0.0",
`a=ice-ufrag:${ufrag}`,
`a=ice-pwd:${pwd}`,
`a=fingerprint:sha-256 ${fingerprint}`,
`a=setup:active`, // Answerer is active (initiates DTLS)
`a=sctp-port:${media.sctpPort}`,
`a=max-message-size:${media.maxMessageSize}`,
].join("\r\n") + "\r\n";
return { type: "answer", sdp };
}
static parseSdp(sdp: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of sdp.split("\r\n")) {
if (!line) continue;
const eqIdx = line.indexOf("=");
const key = line[0];
const value = line.slice(eqIdx + 1);
if (key === "a") {
const colonIdx = value.indexOf(":");
if (colonIdx > 0) {
result[value.slice(0, colonIdx)] = value.slice(colonIdx + 1);
}
} else {
result[key] = value;
}
}
return result;
}
private static randomString(len: number): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < len; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
private static randomFingerprint(): string {
const bytes: string[] = [];
for (let i = 0; i < 32; i++) {
bytes.push(Math.floor(Math.random() * 256).toString(16).padStart(2, "0").toUpperCase());
}
return bytes.join(":");
}
}
Data Channel
type DataChannelState = "connecting" | "open" | "closing" | "closed";
interface DataChannelConfig {
label: string;
ordered?: boolean; // Default: true (TCP-like ordering)
maxRetransmits?: number; // Max re-sends (undefined = reliable)
maxPacketLifeTime?: number; // Max ms before discarding
protocol?: string;
}
type DataChannelHandler = (data: string | ArrayBuffer) => void;
class DataChannel {
readonly label: string;
readonly ordered: boolean;
readonly maxRetransmits?: number;
private state: DataChannelState = "connecting";
private sendQueue: Array<string | ArrayBuffer> = [];
private onMessageHandler?: DataChannelHandler;
private onOpenHandler?: () => void;
private onCloseHandler?: () => void;
private onErrorHandler?: (error: Error) => void;
// Simulated remote peer's channel:
private remotePeer?: DataChannel;
// Buffering:
private bufferedAmount: number = 0;
private readonly BUFFER_THRESHOLD = 65536; // 64KB
constructor(config: DataChannelConfig) {
this.label = config.label;
this.ordered = config.ordered ?? true;
this.maxRetransmits = config.maxRetransmits;
}
/**
* Send data to the remote peer.
* Supports strings and ArrayBuffers.
*/
send(data: string | ArrayBuffer): void {
if (this.state !== "open") {
this.sendQueue.push(data);
return;
}
const size = typeof data === "string"
? new TextEncoder().encode(data).byteLength
: data.byteLength;
this.bufferedAmount += size;
// Simulate network delivery:
queueMicrotask(() => {
this.bufferedAmount -= size;
if (this.remotePeer && this.remotePeer.state === "open") {
if (!this.ordered && Math.random() < 0.01) {
// Simulate 1% packet reordering for unordered channels:
setTimeout(() => this.remotePeer!.receive(data), Math.random() * 10);
} else {
this.remotePeer.receive(data);
}
}
});
}
private receive(data: string | ArrayBuffer): void {
this.onMessageHandler?.(data);
}
open(remotePeer: DataChannel): void {
this.state = "open";
this.remotePeer = remotePeer;
this.onOpenHandler?.();
// Flush queued messages:
for (const data of this.sendQueue) {
this.send(data);
}
this.sendQueue = [];
}
close(): void {
this.state = "closing";
this.remotePeer?.handleRemoteClose();
this.state = "closed";
this.onCloseHandler?.();
}
private handleRemoteClose(): void {
this.state = "closed";
this.onCloseHandler?.();
}
getState(): DataChannelState {
return this.state;
}
getBufferedAmount(): number {
return this.bufferedAmount;
}
onMessage(handler: DataChannelHandler): void {
this.onMessageHandler = handler;
}
onOpen(handler: () => void): void {
this.onOpenHandler = handler;
}
onClose(handler: () => void): void {
this.onCloseHandler = handler;
}
onError(handler: (error: Error) => void): void {
this.onErrorHandler = handler;
}
}
Peer Connection Manager
class PeerConnection {
private peerId: string;
private remotePeerId: string | null = null;
private signalingServer: SignalingServer;
private roomId: string;
private iceAgent: IceAgent;
private dataChannels = new Map<string, DataChannel>();
private localDescription: SessionDescription | null = null;
private remoteDescription: SessionDescription | null = null;
private state: "new" | "connecting" | "connected" | "disconnected" | "failed" = "new";
private leave?: () => void;
// Event handlers:
private onDataChannelHandler?: (channel: DataChannel) => void;
private onStateChangeHandler?: (state: string) => void;
constructor(
peerId: string,
roomId: string,
signalingServer: SignalingServer,
iceConfig: IceConfig
) {
this.peerId = peerId;
this.roomId = roomId;
this.signalingServer = signalingServer;
this.iceAgent = new IceAgent(iceConfig);
// Handle incoming signaling messages:
this.leave = signalingServer.join(roomId, peerId, (message) =>
this.handleSignalingMessage(message)
);
// Forward ICE candidates through signaling:
this.iceAgent.onCandidateGathered((candidate) => {
if (this.remotePeerId) {
signalingServer.send({
type: "ice-candidate",
from: this.peerId,
to: this.remotePeerId,
roomId,
payload: candidate,
});
}
});
this.iceAgent.onConnectionEstablished((pair) => {
this.state = "connected";
this.onStateChangeHandler?.("connected");
// Open all data channels:
for (const channel of this.dataChannels.values()) {
// In the real implementation, SCTP negotiation happens here.
// We simulate by connecting paired channels.
}
});
}
/**
* Create a data channel before or during connection.
*/
createDataChannel(config: DataChannelConfig): DataChannel {
const channel = new DataChannel(config);
this.dataChannels.set(config.label, channel);
return channel;
}
/**
* Initiate connection to a remote peer.
* Caller creates the OFFER.
*/
async connect(remotePeerId: string): Promise<void> {
this.remotePeerId = remotePeerId;
this.state = "connecting";
this.onStateChangeHandler?.("connecting");
// 1. Gather ICE candidates:
await this.iceAgent.gatherCandidates();
// 2. Create SDP offer:
this.localDescription = SdpBuilder.createOffer({
type: "application",
protocol: "DTLS/SCTP",
port: 9,
iceUfrag: "",
icePwd: "",
fingerprint: "",
setup: "actpass",
sctpPort: 5000,
maxMessageSize: 262144,
});
// 3. Send offer via signaling:
this.signalingServer.send({
type: "offer",
from: this.peerId,
to: remotePeerId,
roomId: this.roomId,
payload: this.localDescription,
});
}
private async handleSignalingMessage(message: SignalingMessage): Promise<void> {
switch (message.type) {
case "join": {
// New peer joined — initiate connection:
await this.connect(message.from);
break;
}
case "offer": {
// Received offer — create answer:
this.remotePeerId = message.from;
this.remoteDescription = message.payload;
this.state = "connecting";
await this.iceAgent.gatherCandidates();
this.localDescription = SdpBuilder.createAnswer(
message.payload,
{
type: "application",
protocol: "DTLS/SCTP",
port: 9,
iceUfrag: "",
icePwd: "",
fingerprint: "",
setup: "active",
sctpPort: 5000,
maxMessageSize: 262144,
}
);
this.signalingServer.send({
type: "answer",
from: this.peerId,
to: message.from,
roomId: this.roomId,
payload: this.localDescription,
});
// Start connectivity checks:
await this.iceAgent.checkConnectivity();
break;
}
case "answer": {
// Got answer to our offer:
this.remoteDescription = message.payload;
await this.iceAgent.checkConnectivity();
break;
}
case "ice-candidate": {
// Remote ICE candidate received:
this.iceAgent.addRemoteCandidate(message.payload);
break;
}
case "leave": {
if (message.from === this.remotePeerId) {
this.state = "disconnected";
this.onStateChangeHandler?.("disconnected");
for (const channel of this.dataChannels.values()) {
channel.close();
}
}
break;
}
}
}
disconnect(): void {
this.leave?.();
this.state = "disconnected";
for (const channel of this.dataChannels.values()) {
channel.close();
}
}
getState(): string {
return this.state;
}
onDataChannel(handler: (channel: DataChannel) => void): void {
this.onDataChannelHandler = handler;
}
onStateChange(handler: (state: string) => void): void {
this.onStateChangeHandler = handler;
}
}
Reliable Messaging Layer
/**
* Builds reliable, ordered messaging on top of data channels.
* Handles message acknowledgment, retransmission, and ordering.
*/
interface ReliableMessage {
id: string;
seq: number;
type: string;
payload: any;
timestamp: number;
ack?: number; // Acknowledges messages up to this sequence number
}
class ReliableChannel {
private channel: DataChannel;
private sendSeq: number = 0;
private recvSeq: number = 0;
private pendingAcks = new Map<number, {
message: ReliableMessage;
retries: number;
timer: ReturnType<typeof setTimeout>;
}>();
private recvBuffer = new Map<number, ReliableMessage>(); // Out-of-order buffer
private handlers = new Map<string, (payload: any, from: string) => void>();
private readonly MAX_RETRIES = 5;
private readonly RETRY_INTERVAL_MS = 1000;
private readonly ACK_INTERVAL_MS = 50;
private ackTimer?: ReturnType<typeof setInterval>;
constructor(channel: DataChannel) {
this.channel = channel;
channel.onMessage((data) => {
if (typeof data === "string") {
const message: ReliableMessage = JSON.parse(data);
this.handleMessage(message);
}
});
channel.onOpen(() => {
// Periodic ACK sender:
this.ackTimer = setInterval(() => this.sendAck(), this.ACK_INTERVAL_MS);
});
channel.onClose(() => {
if (this.ackTimer) clearInterval(this.ackTimer);
for (const pending of this.pendingAcks.values()) {
clearTimeout(pending.timer);
}
});
}
send(type: string, payload: any): void {
const seq = ++this.sendSeq;
const message: ReliableMessage = {
id: crypto.randomUUID(),
seq,
type,
payload,
timestamp: Date.now(),
};
this.channel.send(JSON.stringify(message));
// Track for retransmission:
const timer = setTimeout(() => this.retransmit(seq), this.RETRY_INTERVAL_MS);
this.pendingAcks.set(seq, { message, retries: 0, timer });
}
on(type: string, handler: (payload: any, from: string) => void): void {
this.handlers.set(type, handler);
}
private handleMessage(message: ReliableMessage): void {
// Process ACK:
if (message.ack != null) {
// Remove all pending messages up to this seq:
for (const [seq, pending] of this.pendingAcks) {
if (seq <= message.ack) {
clearTimeout(pending.timer);
this.pendingAcks.delete(seq);
}
}
}
// Ignore ACK-only messages:
if (!message.type) return;
// Handle ordering:
if (message.seq === this.recvSeq + 1) {
// In order — deliver:
this.recvSeq = message.seq;
this.deliver(message);
// Check buffer for now-in-order messages:
while (this.recvBuffer.has(this.recvSeq + 1)) {
this.recvSeq++;
const buffered = this.recvBuffer.get(this.recvSeq)!;
this.recvBuffer.delete(this.recvSeq);
this.deliver(buffered);
}
} else if (message.seq > this.recvSeq + 1) {
// Out of order — buffer:
this.recvBuffer.set(message.seq, message);
}
// Duplicate (seq ≤ recvSeq) — ignore
}
private deliver(message: ReliableMessage): void {
const handler = this.handlers.get(message.type);
if (handler) {
handler(message.payload, message.id);
}
}
private retransmit(seq: number): void {
const pending = this.pendingAcks.get(seq);
if (!pending) return;
pending.retries++;
if (pending.retries > this.MAX_RETRIES) {
this.pendingAcks.delete(seq);
console.error(`Message ${seq} dropped after ${this.MAX_RETRIES} retries`);
return;
}
this.channel.send(JSON.stringify(pending.message));
pending.timer = setTimeout(
() => this.retransmit(seq),
this.RETRY_INTERVAL_MS * Math.pow(2, pending.retries) // Exponential backoff
);
}
private sendAck(): void {
if (this.recvSeq > 0) {
const ackMessage: ReliableMessage = {
id: "",
seq: 0,
type: "",
payload: null,
timestamp: Date.now(),
ack: this.recvSeq,
};
this.channel.send(JSON.stringify(ackMessage));
}
}
}
Interview Q&A
Q: Explain the WebRTC connection establishment process. Why is a signaling server needed if WebRTC is peer-to-peer?
WebRTC is peer-to-peer for data transfer, but peers don't know each other's network addresses beforehand. The signaling server is the "introduction service" that passes three things: (1) SDP offer/answer — describes what media/data formats each peer supports and their DTLS fingerprints for encryption, (2) ICE candidates — the possible network paths (local IP, STUN-discovered public IP, TURN relay address), (3) Room/session management — who wants to connect to whom. The flow: Peer A creates an SDP offer, sends it to the signaling server, which forwards it to Peer B. B creates an SDP answer, sends it back. Simultaneously, both peers gather ICE candidates and trickle them to each other through the signaling server. Once a working ICE candidate pair is found, a DTLS handshake establishes an encrypted connection, and SCTP runs on top for data channels. After this, the signaling server is no longer involved — data flows directly peer-to-peer. The signaling server can be any transport: WebSocket, HTTP polling, even email. WebRTC doesn't specify the signaling protocol; it only specifies the peer-to-peer protocols.
Q: What's the difference between STUN and TURN, and when is TURN necessary?
STUN (Session Traversal Utilities for NAT) helps a peer discover its public IP address and port. The peer sends a binding request to a STUN server; the server reads the source IP/port from the packet (which is the NAT's external mapping) and sends it back. Cost: one UDP round trip, ~50ms. STUN is cheap — Google runs free STUN servers. It works when NATs create predictable, reusable port mappings (cone NATs). TURN (Traversal Using Relays around NAT) is a full relay server. When STUN fails — typically with symmetric NATs that create a different external port for each destination — TURN allocates a relay address on the server. All data flows through the TURN server: Peer A → TURN → Peer B. This adds latency (~50-100ms round trip increase) and bandwidth cost (the server handles all traffic), but it always works. In practice, about 85% of connections succeed with STUN only, 10% need TURN for one side, and 5% need TURN for both. Enterprise networks with strict firewalls almost always require TURN. The ICE framework tries candidates in order of preference (host → srflx → relay), so TURN is only used as a fallback.
Q: How do WebRTC data channels compare to WebSockets for real-time communication?
WebSockets: client-server, TCP-based, reliable and ordered, all traffic through the server. Great for chat, notifications, real-time updates where a server needs to see the data. WebRTC data channels: peer-to-peer, SCTP over DTLS/UDP, configurable reliability (reliable, partial-reliability with max retransmits, or unreliable). Key differences: (1) Latency: data channels skip the server round trip — peer A to peer B is one hop, not A→server→B. For a game with 20ms ping between peers, WebSocket adds 40ms (A→server + server→B). (2) Reliability options: data channels can be configured as unreliable (like UDP) — ideal for real-time game state where the latest update makes previous ones obsolete. WebSockets are always reliable/ordered. (3) Server cost: P2P data doesn't consume server bandwidth. A video call between 2 peers uses 0 server bandwidth with data channels vs. full bandwidth relay with WebSocket. (4) NAT traversal: data channels require the complex ICE/STUN/TURN setup. WebSockets "just work" through any firewall. (5) Connection setup: data channels take 1-3 seconds (ICE + DTLS). WebSockets connect in ~100ms. Use WebSocket when you need server-side processing or simple broadcast. Use data channels when latency, bandwidth cost, or privacy matters.
What did you think?