Intermediate
20 min
Full Guide
WebRTC Signaling
Learn how peers discover and connect to each other using signaling servers
What is Signaling?
Signaling is the process of coordinating communication between WebRTC peers. Before two browsers can talk directly, they need to exchange information about how to connect—this happens through a signaling server.
WebRTC intentionally doesn't define a signaling protocol. You can use WebSockets, HTTP, Socket.io, or any other method to exchange signaling messages. This flexibility lets you integrate WebRTC into existing infrastructure.
📡 What Gets Exchanged
SDP (Session Description)
- • Offer - Caller's capabilities
- • Answer - Callee's response
- • Codecs, encryption, bandwidth
- • Media types (audio/video)
ICE Candidates
- • Host candidates (local IPs)
- • Server-reflexive (STUN)
- • Relay candidates (TURN)
- • Connection paths to try
Building a Signaling Server
// Signaling server with Socket.io
const { Server } = require('socket.io');
const http = require('http');
const server = http.createServer();
const io = new Server(server, {
cors: { origin: '*' }
});
// Store connected users
const users = new Map();
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
// User registers with a username
socket.on('register', (username) => {
users.set(username, socket.id);
socket.username = username;
// Broadcast user list
io.emit('users', Array.from(users.keys()));
});
// Forward offer to specific user
socket.on('offer', ({ to, offer }) => {
const targetSocketId = users.get(to);
if (targetSocketId) {
io.to(targetSocketId).emit('offer', {
from: socket.username,
offer: offer
});
}
});
// Forward answer to caller
socket.on('answer', ({ to, answer }) => {
const targetSocketId = users.get(to);
if (targetSocketId) {
io.to(targetSocketId).emit('answer', {
from: socket.username,
answer: answer
});
}
});
// Forward ICE candidates
socket.on('ice-candidate', ({ to, candidate }) => {
const targetSocketId = users.get(to);
if (targetSocketId) {
io.to(targetSocketId).emit('ice-candidate', {
from: socket.username,
candidate: candidate
});
}
});
// Handle call request
socket.on('call-request', ({ to }) => {
const targetSocketId = users.get(to);
if (targetSocketId) {
io.to(targetSocketId).emit('incoming-call', {
from: socket.username
});
} else {
socket.emit('call-failed', { reason: 'User not found' });
}
});
// Handle call response
socket.on('call-response', ({ to, accepted }) => {
const targetSocketId = users.get(to);
if (targetSocketId) {
io.to(targetSocketId).emit('call-response', {
from: socket.username,
accepted: accepted
});
}
});
// Handle disconnect
socket.on('disconnect', () => {
if (socket.username) {
users.delete(socket.username);
io.emit('users', Array.from(users.keys()));
}
});
});
server.listen(3001, () => {
console.log('Signaling server running on port 3001');
});
Client-Side Signaling Handler
// Complete WebRTC client with signaling
class WebRTCClient {
constructor(signalingUrl) {
this.socket = io(signalingUrl);
this.peerConnection = null;
this.localStream = null;
this.remoteStream = null;
this.currentPeer = null;
// ICE servers configuration
this.config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
this.setupSocketListeners();
}
setupSocketListeners() {
// Receive incoming call request
this.socket.on('incoming-call', async ({ from }) => {
const accepted = confirm(`Incoming call from ${from}. Accept?`);
this.socket.emit('call-response', { to: from, accepted });
if (accepted) {
this.currentPeer = from;
await this.initializePeerConnection();
// Wait for offer
}
});
// Receive call response
this.socket.on('call-response', async ({ from, accepted }) => {
if (accepted) {
console.log('Call accepted, sending offer...');
await this.initializePeerConnection();
await this.createAndSendOffer(from);
} else {
console.log('Call rejected');
this.cleanup();
}
});
// Receive offer
this.socket.on('offer', async ({ from, offer }) => {
console.log('Received offer from:', from);
this.currentPeer = from;
if (!this.peerConnection) {
await this.initializePeerConnection();
}
await this.peerConnection.setRemoteDescription(
new RTCSessionDescription(offer)
);
const answer = await this.peerConnection.createAnswer();
await this.peerConnection.setLocalDescription(answer);
this.socket.emit('answer', {
to: from,
answer: answer
});
});
// Receive answer
this.socket.on('answer', async ({ from, answer }) => {
console.log('Received answer from:', from);
await this.peerConnection.setRemoteDescription(
new RTCSessionDescription(answer)
);
});
// Receive ICE candidate
this.socket.on('ice-candidate', async ({ from, candidate }) => {
console.log('Received ICE candidate from:', from);
if (candidate && this.peerConnection) {
await this.peerConnection.addIceCandidate(
new RTCIceCandidate(candidate)
);
}
});
}
async initializePeerConnection() {
this.peerConnection = new RTCPeerConnection(this.config);
// Handle ICE candidates
this.peerConnection.onicecandidate = (event) => {
if (event.candidate) {
this.socket.emit('ice-candidate', {
to: this.currentPeer,
candidate: event.candidate
});
}
};
// Handle connection state
this.peerConnection.onconnectionstatechange = () => {
console.log('Connection state:', this.peerConnection.connectionState);
if (this.peerConnection.connectionState === 'connected') {
this.onConnected && this.onConnected();
} else if (this.peerConnection.connectionState === 'failed') {
this.onFailed && this.onFailed();
this.cleanup();
}
};
// Handle remote stream
this.peerConnection.ontrack = (event) => {
console.log('Received remote track');
this.remoteStream = event.streams[0];
this.onRemoteStream && this.onRemoteStream(this.remoteStream);
};
// Add local stream if available
if (this.localStream) {
this.localStream.getTracks().forEach(track => {
this.peerConnection.addTrack(track, this.localStream);
});
}
}
async getLocalStream(video = true, audio = true) {
this.localStream = await navigator.mediaDevices.getUserMedia({
video: video,
audio: audio
});
return this.localStream;
}
async createAndSendOffer(to) {
const offer = await this.peerConnection.createOffer();
await this.peerConnection.setLocalDescription(offer);
this.socket.emit('offer', {
to: to,
offer: offer
});
}
// Public API
register(username) {
this.socket.emit('register', username);
}
async callUser(username) {
this.currentPeer = username;
this.socket.emit('call-request', { to: username });
}
hangUp() {
this.socket.emit('hang-up', { to: this.currentPeer });
this.cleanup();
}
cleanup() {
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
this.localStream = null;
}
this.currentPeer = null;
}
}
// Usage
const client = new WebRTCClient('http://localhost:3001');
client.onRemoteStream = (stream) => {
document.getElementById('remoteVideo').srcObject = stream;
};
client.onConnected = () => {
console.log('Call connected!');
};
// Register and start call
client.register('Alice');
const localStream = await client.getLocalStream();
document.getElementById('localVideo').srcObject = localStream;
await client.callUser('Bob');
The Offer/Answer Exchange
// Detailed look at the offer/answer dance
// STEP 1: Caller creates offer
async function createOffer(pc) {
// Options for the offer
const offerOptions = {
offerToReceiveAudio: true,
offerToReceiveVideo: true,
iceRestart: false // Set true to restart ICE
};
const offer = await pc.createOffer(offerOptions);
// SDP contains session description
console.log('Offer SDP:', offer.sdp);
/*
v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104
...
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98
...
*/
// Set as local description
await pc.setLocalDescription(offer);
return offer;
}
// STEP 2: Callee receives offer, creates answer
async function handleOffer(pc, offer) {
// Set received offer as remote description
await pc.setRemoteDescription(new RTCSessionDescription(offer));
// Create answer
const answer = await pc.createAnswer();
// Set as local description
await pc.setLocalDescription(answer);
return answer;
}
// STEP 3: Caller receives answer
async function handleAnswer(pc, answer) {
await pc.setRemoteDescription(new RTCSessionDescription(answer));
// Connection negotiation complete!
}
ICE Candidate Exchange
// ICE candidates are gathered after setLocalDescription
const pc = new RTCPeerConnection(config);
// Candidates trickle in as they're discovered
pc.onicecandidate = (event) => {
if (event.candidate) {
// Candidate found - send to peer
console.log('New ICE candidate:', event.candidate);
/*
{
candidate: "candidate:842163049 1 udp 1677729535 192.168.1.100 54321 typ srflx...",
sdpMid: "0",
sdpMLineIndex: 0,
usernameFragment: "sdfg"
}
*/
signalingChannel.send({
type: 'ice-candidate',
candidate: event.candidate,
to: remotePeer
});
} else {
// null candidate means gathering is complete
console.log('ICE gathering complete');
}
};
// Alternative: Wait for all candidates (not recommended)
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
// All candidates included in localDescription.sdp
const offerWithCandidates = pc.localDescription;
// Send complete offer (slower startup but simpler)
}
};
// Receiving candidates from peer
async function handleIceCandidate(candidateData) {
try {
await pc.addIceCandidate(new RTCIceCandidate(candidateData));
console.log('Added ICE candidate successfully');
} catch (error) {
// Can fail if added before remote description is set
console.error('Failed to add ICE candidate:', error);
}
}
// Handle ICE candidate buffering (common issue)
const pendingCandidates = [];
async function handleIceCandidateSafe(candidate) {
if (pc.remoteDescription) {
await pc.addIceCandidate(candidate);
} else {
// Buffer until remote description is set
pendingCandidates.push(candidate);
}
}
async function handleRemoteDescription(description) {
await pc.setRemoteDescription(description);
// Process buffered candidates
for (const candidate of pendingCandidates) {
await pc.addIceCandidate(candidate);
}
pendingCandidates.length = 0;
}
Perfect Negotiation Pattern
// The "perfect negotiation" pattern handles edge cases
// Problem: What if both peers try to create offers simultaneously?
// Solution: Designate one as "polite" (yields) and one as "impolite"
class PerfectNegotiation {
constructor(pc, signalingChannel, polite) {
this.pc = pc;
this.signaling = signalingChannel;
this.polite = polite; // true = yield on collisions
this.makingOffer = false;
this.ignoreOffer = false;
this.setup();
}
setup() {
// Handle negotiation needed (renegotiation)
this.pc.onnegotiationneeded = async () => {
try {
this.makingOffer = true;
await this.pc.setLocalDescription();
this.signaling.send({
type: 'description',
description: this.pc.localDescription
});
} catch (err) {
console.error('Negotiation failed:', err);
} finally {
this.makingOffer = false;
}
};
this.pc.onicecandidate = ({ candidate }) => {
this.signaling.send({ type: 'candidate', candidate });
};
// Handle incoming messages
this.signaling.on('message', async (msg) => {
try {
if (msg.type === 'description') {
await this.handleDescription(msg.description);
} else if (msg.type === 'candidate') {
await this.pc.addIceCandidate(msg.candidate);
}
} catch (err) {
console.error('Signaling error:', err);
}
});
}
async handleDescription(description) {
const offerCollision =
description.type === 'offer' &&
(this.makingOffer || this.pc.signalingState !== 'stable');
this.ignoreOffer = !this.polite && offerCollision;
if (this.ignoreOffer) {
console.log('Ignoring offer (collision, impolite peer)');
return;
}
await this.pc.setRemoteDescription(description);
if (description.type === 'offer') {
await this.pc.setLocalDescription();
this.signaling.send({
type: 'description',
description: this.pc.localDescription
});
}
}
}
// Usage
const pc = new RTCPeerConnection(config);
const polite = determineIfPolite(); // e.g., based on user ID comparison
const negotiation = new PerfectNegotiation(pc, signalingChannel, polite);
💡 Signaling Best Practices
- ✓ Use WebSocket for signaling - Low latency, bidirectional
- ✓ Buffer ICE candidates - Wait for remote description
- ✓ Handle trickle ICE - Don't wait for all candidates
- ✓ Implement perfect negotiation - Handle offer collisions
- ✓ Authenticate users - Verify identity before connecting
- ✓ Keep signaling minimal - Only exchange necessary data