WebRTC: Peer-to-Peer Communication
WebRTC (Web Real-Time Communication) is the technology that powers video calls in your browser without installing Zoom or Skype. It's a set of APIs and protocols that enable peer-to-peer audio,...
Key Insights
- WebRTC enables direct browser-to-browser communication without plugins, but still requires a signaling server to coordinate the initial connection handshake between peers.
- The ICE framework handles NAT traversal automatically, but you’ll need TURN servers as a fallback for roughly 10-15% of connections that can’t establish direct peer routes.
- Data channels are criminally underused—they provide low-latency, encrypted communication for gaming, file sharing, and real-time collaboration without touching your servers.
Introduction to WebRTC
WebRTC (Web Real-Time Communication) is the technology that powers video calls in your browser without installing Zoom or Skype. It’s a set of APIs and protocols that enable peer-to-peer audio, video, and data transfer directly between browsers.
The “peer-to-peer” part is crucial. Once a connection is established, media and data flow directly between users without routing through your servers. This means lower latency, reduced bandwidth costs, and better privacy since you’re not an intermediary for user communications.
Browser support is excellent—Chrome, Firefox, Safari, and Edge all support WebRTC. Use cases extend beyond video chat: multiplayer gaming, collaborative document editing, file sharing, screen sharing, and IoT device communication all benefit from WebRTC’s low-latency direct connections.
Core Architecture & Concepts
WebRTC’s architecture confuses newcomers because “peer-to-peer” doesn’t mean “serverless.” You need infrastructure for two distinct purposes:
Signaling servers coordinate the connection handshake. They exchange connection metadata (SDP offers/answers) and network candidates between peers. WebRTC doesn’t specify how signaling works—use WebSockets, HTTP polling, carrier pigeons, whatever. The signaling server never sees the actual media or data.
ICE/STUN/TURN servers help peers find each other across the internet:
- STUN (Session Traversal Utilities for NAT) servers tell peers their public IP address and port. Most connections only need STUN.
- TURN (Traversal Using Relays around NAT) servers relay traffic when direct connections fail. They’re expensive to run but essential for symmetric NATs and restrictive firewalls.
- ICE (Interactive Connectivity Establishment) is the framework that tries multiple connection paths and picks the best one.
NAT traversal is the hard problem WebRTC solves. Most devices sit behind routers that block incoming connections. ICE systematically tries connection methods—local network, STUN-discovered public addresses, and TURN relay—until something works.
Establishing a Peer Connection
The RTCPeerConnection API is your primary interface. Here’s how to create and configure one:
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:your-turn-server.com:3478',
username: 'user',
credential: 'password'
}
]
};
const peerConnection = new RTCPeerConnection(configuration);
// Handle ICE candidates - send these to the remote peer via signaling
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
signalingChannel.send({
type: 'ice-candidate',
candidate: event.candidate
});
}
};
// Handle connection state changes
peerConnection.onconnectionstatechange = () => {
console.log('Connection state:', peerConnection.connectionState);
if (peerConnection.connectionState === 'failed') {
// Handle reconnection
}
};
// Handle incoming tracks (remote video/audio)
peerConnection.ontrack = (event) => {
remoteVideo.srcObject = event.streams[0];
};
The connection process follows an offer/answer model using SDP (Session Description Protocol):
- Caller creates an offer describing their capabilities
- Caller sets the offer as their local description
- Caller sends the offer to the callee via signaling
- Callee sets the received offer as their remote description
- Callee creates an answer
- Callee sets the answer as their local description
- Callee sends the answer back via signaling
- Caller sets the received answer as their remote description
// Caller side
async function createOffer() {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
signalingChannel.send({ type: 'offer', sdp: offer });
}
// Callee side
async function handleOffer(offer) {
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
signalingChannel.send({ type: 'answer', sdp: answer });
}
// Caller receives answer
async function handleAnswer(answer) {
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
}
Signaling Implementation
Here’s a minimal WebSocket signaling server in Node.js:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
const rooms = new Map();
wss.on('connection', (ws) => {
let currentRoom = null;
let peerId = null;
ws.on('message', (data) => {
const message = JSON.parse(data);
switch (message.type) {
case 'join':
peerId = message.peerId;
currentRoom = message.room;
if (!rooms.has(currentRoom)) {
rooms.set(currentRoom, new Map());
}
rooms.get(currentRoom).set(peerId, ws);
// Notify others in room
broadcastToRoom(currentRoom, peerId, {
type: 'peer-joined',
peerId: peerId
});
break;
case 'offer':
case 'answer':
case 'ice-candidate':
// Forward to specific peer
const targetPeer = rooms.get(currentRoom)?.get(message.target);
if (targetPeer) {
targetPeer.send(JSON.stringify({
...message,
from: peerId
}));
}
break;
}
});
ws.on('close', () => {
if (currentRoom && peerId) {
rooms.get(currentRoom)?.delete(peerId);
broadcastToRoom(currentRoom, peerId, {
type: 'peer-left',
peerId: peerId
});
}
});
});
function broadcastToRoom(room, excludePeerId, message) {
const peers = rooms.get(room);
if (!peers) return;
peers.forEach((ws, id) => {
if (id !== excludePeerId) {
ws.send(JSON.stringify(message));
}
});
}
Client-side signaling logic:
class SignalingChannel {
constructor(serverUrl) {
this.ws = new WebSocket(serverUrl);
this.handlers = new Map();
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
const handler = this.handlers.get(message.type);
if (handler) handler(message);
};
}
on(type, handler) {
this.handlers.set(type, handler);
}
send(message) {
this.ws.send(JSON.stringify(message));
}
join(room, peerId) {
this.send({ type: 'join', room, peerId });
}
}
Media Streams & Data Channels
Capturing media is straightforward with getUserMedia:
async function startVideoCall(peerConnection) {
// Get local media
const localStream = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720 },
audio: true
});
// Display local video
localVideo.srcObject = localStream;
// Add tracks to peer connection
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
return localStream;
}
// Screen sharing
async function shareScreen(peerConnection) {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { cursor: 'always' },
audio: true
});
const videoTrack = screenStream.getVideoTracks()[0];
const sender = peerConnection.getSenders()
.find(s => s.track?.kind === 'video');
if (sender) {
await sender.replaceTrack(videoTrack);
}
return screenStream;
}
Data channels enable arbitrary data transfer with configurable reliability:
// Create data channel (caller side - before creating offer)
const dataChannel = peerConnection.createDataChannel('files', {
ordered: true // Use ordered: false for gaming/real-time apps
});
dataChannel.onopen = () => console.log('Data channel open');
dataChannel.onmessage = (event) => handleIncomingData(event.data);
// Receive data channel (callee side)
peerConnection.ondatachannel = (event) => {
const channel = event.channel;
channel.onmessage = (event) => handleIncomingData(event.data);
};
// Send a file
async function sendFile(file, dataChannel) {
const chunkSize = 16384; // 16KB chunks
const fileReader = new FileReader();
let offset = 0;
// Send metadata first
dataChannel.send(JSON.stringify({
type: 'file-start',
name: file.name,
size: file.size
}));
fileReader.onload = (e) => {
dataChannel.send(e.target.result);
offset += e.target.result.byteLength;
if (offset < file.size) {
readSlice(offset);
} else {
dataChannel.send(JSON.stringify({ type: 'file-end' }));
}
};
const readSlice = (o) => {
const slice = file.slice(o, o + chunkSize);
fileReader.readAsArrayBuffer(slice);
};
readSlice(0);
}
Handling Real-World Challenges
Production WebRTC requires robust error handling:
class RobustPeerConnection {
constructor(config, signalingChannel) {
this.config = config;
this.signaling = signalingChannel;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 3;
this.createConnection();
}
createConnection() {
this.pc = new RTCPeerConnection(this.config);
this.pc.oniceconnectionstatechange = () => {
const state = this.pc.iceConnectionState;
console.log('ICE state:', state);
if (state === 'failed') {
this.handleConnectionFailure();
} else if (state === 'disconnected') {
// Wait briefly - might recover
setTimeout(() => {
if (this.pc.iceConnectionState === 'disconnected') {
this.handleConnectionFailure();
}
}, 5000);
} else if (state === 'connected') {
this.reconnectAttempts = 0;
}
};
}
async handleConnectionFailure() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.onFatalError?.('Connection failed after max retries');
return;
}
this.reconnectAttempts++;
console.log(`Reconnect attempt ${this.reconnectAttempts}`);
// ICE restart
const offer = await this.pc.createOffer({ iceRestart: true });
await this.pc.setLocalDescription(offer);
this.signaling.send({ type: 'offer', sdp: offer, iceRestart: true });
}
}
Security is largely handled for you—WebRTC mandates DTLS encryption for all connections. However, you must:
- Always request user permission for media access
- Validate signaling messages to prevent impersonation
- Use secure WebSocket (wss://) for signaling
- Implement authentication in your signaling server
Putting It Together: Mini Project
Here’s a complete video chat implementation:
class VideoChat {
constructor(roomId, userId) {
this.roomId = roomId;
this.userId = userId;
this.peers = new Map();
this.signaling = new SignalingChannel('wss://your-server.com');
this.setupSignaling();
}
setupSignaling() {
this.signaling.on('peer-joined', async (msg) => {
// New peer joined - create offer
const pc = this.createPeerConnection(msg.peerId);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this.signaling.send({
type: 'offer',
target: msg.peerId,
sdp: offer
});
});
this.signaling.on('offer', async (msg) => {
const pc = this.createPeerConnection(msg.from);
await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.signaling.send({
type: 'answer',
target: msg.from,
sdp: answer
});
});
this.signaling.on('answer', async (msg) => {
const pc = this.peers.get(msg.from);
await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
});
this.signaling.on('ice-candidate', async (msg) => {
const pc = this.peers.get(msg.from);
await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
});
}
createPeerConnection(peerId) {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
this.peers.set(peerId, pc);
pc.onicecandidate = (e) => {
if (e.candidate) {
this.signaling.send({
type: 'ice-candidate',
target: peerId,
candidate: e.candidate
});
}
};
pc.ontrack = (e) => {
this.onRemoteStream?.(peerId, e.streams[0]);
};
// Add local tracks
if (this.localStream) {
this.localStream.getTracks().forEach(track => {
pc.addTrack(track, this.localStream);
});
}
return pc;
}
async start() {
this.localStream = await navigator.mediaDevices.getUserMedia({
video: true, audio: true
});
this.onLocalStream?.(this.localStream);
this.signaling.join(this.roomId, this.userId);
}
}
For deployment, use a managed TURN service (Twilio, Xirsys) rather than running your own—TURN servers consume significant bandwidth. Your signaling server scales horizontally with Redis pub/sub for multi-instance coordination.
WebRTC handles the hard parts of real-time communication. Your job is reliable signaling and graceful error recovery. Start simple, test across networks, and add TURN servers before users complain about connection failures.