WebRTC: Real-Time Communication in the Browser
WebRTC enables real-time peer-to-peer communication in browsers. Understanding the architecture, signaling flow, and media handling is crucial for building video conferencing, voice calls, and real-time collaboration features.
Quick Decision Guide
Quick Guide:
Architecture: Peer-to-peer connection with signaling server for offer/answer exchange Media Streams: getUserMedia() for camera/mic, getDisplayMedia() for screen share Signaling: WebSocket or HTTP for exchanging SDP offers/answers and ICE candidates STUN/TURN: STUN for NAT traversal, TURN for relay when direct connection fails Data Channels: Bidirectional data transfer for file sharing, gaming, chat
Result: Direct browser-to-browser communication without plugins or servers in the media path.
WebRTC Architecture
WebRTC Architecture Overview
WebRTC enables direct peer-to-peer communication between browsers. The architecture consists of three main components:
1. Media Capture: Access camera, microphone, screen
2. Signaling: Exchange connection information (not part of WebRTC spec)
3. Peer Connection: Establish and manage peer-to-peer connection
The WebRTC Flow
βββββββββββββββ βββββββββββββββ
β Browser β β Browser β
β A β β B β
ββββββββ¬βββββββ ββββββββ¬βββββββ
β β
β 1. Create offer β
ββββββββββββββββββββββββββββββββββββΆβ
β β
β 2. Set local description β
β β
β 3. Exchange ICE candidates β
ββββββββββββββββββββββββββββββββββββ
β β
β 4. Create answer β
β β
β 5. Set remote description β
β β
β 6. Connection established β
ββββββββββββββββββββββββββββββββββββΆβ
β β
β 7. Media streams flow β
ββββββββββββββββββββββββββββββββββββΆβ
β β
ββββββββ΄ββββββββββββββββββββββββββββββββββββ΄βββββββKey Components
1. RTCPeerConnection
The main API for establishing peer-to-peer connections.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:turnserver.com', username: 'user', credential: 'pass' }
]
});Configuration:
iceServers: STUN/TURN servers for NAT traversaliceCandidatePoolSize: Pre-gather ICE candidatesbundlePolicy: Bundle multiple media streams2. Media Streams
Capture audio/video from user's device.
// Get user media (camera + microphone)
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
// Add stream to peer connection
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
});3. Signaling
Exchange connection information (not part of WebRTC - you implement this).
// Send offer to signaling server
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signalingServer.send({ type: 'offer', offer });
// Receive answer from signaling server
signalingServer.on('answer', async (answer) => {
await pc.setRemoteDescription(answer);
});Why Signaling is Needed
WebRTC doesn't specify how peers find each other. You need a signaling server (WebSocket, HTTP, etc.) to:
1. Exchange SDP offers/answers: Connection parameters
2. Exchange ICE candidates: Network addresses
3. Coordinate connection: Who initiates, who responds
Important: Signaling server is only for coordination - media flows directly between peers (peer-to-peer).
Signaling: Offer/Answer Exchange
Signaling Flow
Signaling is the process of exchanging connection information between peers. WebRTC doesn't specify how to do this - you implement it using WebSocket, HTTP, or any other method.
The Offer/Answer Pattern
Step 1: Create Offer (Initiator)
// Peer A creates offer
const pc = new RTCPeerConnection(config);
// Add local media stream
const stream = await getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => pc.addTrack(track, stream));
// Create and set local description
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Send offer to signaling server
signalingServer.send({
type: 'offer',
offer: offer,
from: 'peerA',
to: 'peerB'
});Step 2: Receive Offer and Create Answer (Responder)
// Peer B receives offer
signalingServer.on('offer', async ({ offer, from }) => {
const pc = new RTCPeerConnection(config);
// Set remote description (offer from peer A)
await pc.setRemoteDescription(offer);
// Add local media stream
const stream = await getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => pc.addTrack(track, stream));
// Create and set local description (answer)
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// Send answer to signaling server
signalingServer.send({
type: 'answer',
answer: answer,
from: 'peerB',
to: 'peerA'
});
});Step 3: Receive Answer (Initiator)
// Peer A receives answer
signalingServer.on('answer', async ({ answer }) => {
await pc.setRemoteDescription(answer);
// Connection established!
});ICE Candidate Exchange
ICE (Interactive Connectivity Establishment) candidates are network addresses that peers can use to connect.
Gathering ICE Candidates
// Listen for ICE candidates
pc.onicecandidate = (event) => {
if (event.candidate) {
// Send candidate to peer via signaling server
signalingServer.send({
type: 'ice-candidate',
candidate: event.candidate,
from: 'peerA',
to: 'peerB'
});
} else {
// All candidates gathered
console.log('ICE gathering complete');
}
};Receiving ICE Candidates
// Receive ICE candidate from peer
signalingServer.on('ice-candidate', async ({ candidate }) => {
await pc.addIceCandidate(candidate);
});Connection State Monitoring
// Monitor connection state
pc.onconnectionstatechange = () => {
console.log('Connection state:', pc.connectionState);
// States: 'new', 'connecting', 'connected', 'disconnected', 'failed', 'closed'
};
// Monitor ICE connection state
pc.oniceconnectionstatechange = () => {
console.log('ICE connection state:', pc.iceConnectionState);
// States: 'new', 'checking', 'connected', 'completed', 'failed', 'disconnected', 'closed'
};Complete Signaling Example
// Signaling server (WebSocket example)
class SignalingClient {
constructor(wsUrl) {
this.ws = new WebSocket(wsUrl);
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
}
send(message) {
this.ws.send(JSON.stringify(message));
}
on(event, handler) {
this.handlers = this.handlers || {};
this.handlers[event] = handler;
}
handleMessage(message) {
const handler = this.handlers[message.type];
if (handler) handler(message);
}
}
// Usage
const signaling = new SignalingClient('ws://signaling-server.com');
// Send offer
signaling.send({ type: 'offer', offer, from: 'peerA', to: 'peerB' });
// Listen for answer
signaling.on('answer', async ({ answer }) => {
await pc.setRemoteDescription(answer);
});Media Streams: Camera, Microphone, Screen
Getting User Media
Camera and Microphone
// Get user media with constraints
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user' // or 'environment' for back camera
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
// Display in video element
const videoElement = document.getElementById('localVideo');
videoElement.srcObject = stream;Constraints:
width, height: Video resolutionfacingMode: Front or back cameraframeRate: Frames per secondechoCancellation, noiseSuppression: Audio qualityScreen Sharing
// Get display media (screen share)
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
displaySurface: 'monitor', // or 'window', 'browser'
cursor: 'always' // or 'never', 'motion'
},
audio: true // Share system audio
});
// Display in video element
const screenElement = document.getElementById('screenShare');
screenElement.srcObject = screenStream;Display Surface Options:
monitor: Entire screenwindow: Application windowbrowser: Browser tabAdding Media to Peer Connection
// Add tracks to peer connection
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
});
// Or add transceiver (more control)
const transceiver = pc.addTransceiver('video', {
direction: 'sendrecv', // or 'sendonly', 'recvonly', 'inactive'
streams: [stream]
});Receiving Remote Media
// Listen for remote tracks
pc.ontrack = (event) => {
const [remoteStream] = event.streams;
const remoteVideo = document.getElementById('remoteVideo');
remoteVideo.srcObject = remoteStream;
};Controlling Media Tracks
// Mute/unmute audio
const audioTrack = stream.getAudioTracks()[0];
audioTrack.enabled = false; // Mute
// Enable/disable video
const videoTrack = stream.getVideoTracks()[0];
videoTrack.enabled = false; // Disable video
// Stop track (release camera/mic)
videoTrack.stop();Switching Cameras
// Get available devices
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(device => device.kind === 'videoinput');
// Switch to specific camera
const newStream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: videoDevices[1].deviceId } }
});
// Replace old track
const oldTrack = stream.getVideoTracks()[0];
const newTrack = newStream.getVideoTracks()[0];
oldTrack.stop();
stream.removeTrack(oldTrack);
stream.addTrack(newTrack);Media Constraints Best Practices
// Adaptive constraints based on network
const getAdaptiveConstraints = (networkQuality) => {
if (networkQuality === 'poor') {
return {
video: { width: 640, height: 480, frameRate: 15 },
audio: { echoCancellation: true }
};
} else if (networkQuality === 'good') {
return {
video: { width: 1280, height: 720, frameRate: 30 },
audio: { echoCancellation: true, noiseSuppression: true }
};
}
};Data Channels: Peer-to-Peer Data Transfer
Data Channels Overview
Data channels enable bidirectional peer-to-peer data transfer. Unlike media streams, data channels are for arbitrary data (files, messages, game state, etc.).
Creating Data Channels
Reliable Data Channel
// Create reliable data channel (ordered, guaranteed delivery)
const dataChannel = pc.createDataChannel('messages', {
ordered: true, // Messages arrive in order
maxRetransmits: 3 // Max retransmission attempts
});
// Listen for messages
dataChannel.onmessage = (event) => {
console.log('Received:', event.data);
};
// Send message
dataChannel.send('Hello, peer!');Unreliable Data Channel
// Create unreliable data channel (low latency, may drop packets)
const gameChannel = pc.createDataChannel('game-state', {
ordered: false, // Messages may arrive out of order
maxRetransmits: 0 // No retransmission (low latency)
});Receiving Data Channels
// Listen for incoming data channels
pc.ondatachannel = (event) => {
const dataChannel = event.channel;
dataChannel.onopen = () => {
console.log('Data channel opened');
};
dataChannel.onmessage = (event) => {
console.log('Received:', event.data);
};
dataChannel.onclose = () => {
console.log('Data channel closed');
};
};Data Channel States
// Monitor data channel state
const states = ['connecting', 'open', 'closing', 'closed'];
dataChannel.onopen = () => {
console.log('Channel state: open');
// Channel is ready to send/receive
};
dataChannel.onclose = () => {
console.log('Channel state: closed');
};
dataChannel.onerror = (error) => {
console.error('Channel error:', error);
};File Transfer Example
// Sender: Send file
async function sendFile(file) {
const reader = new FileReader();
reader.onload = (event) => {
const arrayBuffer = event.target.result;
const chunkSize = 16 * 1024; // 16KB chunks
// Send file metadata first
dataChannel.send(JSON.stringify({
type: 'file-start',
name: file.name,
size: file.size,
type: file.type
}));
// Send file in chunks
for (let offset = 0; offset < arrayBuffer.byteLength; offset += chunkSize) {
const chunk = arrayBuffer.slice(offset, offset + chunkSize);
dataChannel.send(chunk);
}
// Send file end marker
dataChannel.send(JSON.stringify({ type: 'file-end' }));
};
reader.readAsArrayBuffer(file);
}
// Receiver: Receive file
let receivedChunks = [];
let fileMetadata = null;
dataChannel.onmessage = (event) => {
if (typeof event.data === 'string') {
const message = JSON.parse(event.data);
if (message.type === 'file-start') {
fileMetadata = message;
receivedChunks = [];
} else if (message.type === 'file-end') {
// Reconstruct file
const blob = new Blob(receivedChunks, { type: fileMetadata.type });
const url = URL.createObjectURL(blob);
// Download file
const a = document.createElement('a');
a.href = url;
a.download = fileMetadata.name;
a.click();
}
} else {
// Binary chunk
receivedChunks.push(event.data);
}
};Use Cases for Data Channels
1. File Transfer: Send files directly between peers
2. Chat Messages: Real-time text communication
3. Game State: Synchronize game state in multiplayer games
4. Collaborative Editing: Real-time document collaboration
5. Control Signals: Send control commands (mute, video toggle)
Best Practices
STUN and TURN Servers
Why STUN/TURN Servers?
Most devices are behind NAT (Network Address Translation) or firewalls, which makes direct peer-to-peer connections difficult. STUN and TURN servers help establish connections.
STUN Servers
STUN (Session Traversal Utilities for NAT) servers help discover your public IP address.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
});What STUN does:
When STUN works:
TURN Servers
TURN (Traversal Using Relays around NAT) servers relay traffic when direct connection fails.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turnserver.com:3478',
username: 'user',
credential: 'password'
}
]
});What TURN does:
When TURN is needed:
ICE Candidate Types
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log('Candidate type:', event.candidate.type);
// Types: 'host', 'srflx', 'prflx', 'relay'
}
};Candidate Types:
host: Local IP address (direct connection)srflx: Server reflexive (via STUN, public IP)prflx: Peer reflexive (discovered during connection)relay: Relayed (via TURN server)Connection Priority
WebRTC tries connections in order of preference:
1. Host (direct): Fastest, lowest latency
2. Server reflexive (STUN): Good, direct connection via public IP
3. Peer reflexive: Discovered during connection
4. Relay (TURN): Fallback, higher latency
TURN Server Setup
Using a TURN Service
// Example: Using Twilio TURN service
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:global.turn.twilio.com:3478?transport=udp',
username: 'your-username',
credential: 'your-credential'
},
{
urls: 'turn:global.turn.twilio.com:3478?transport=tcp',
username: 'your-username',
credential: 'your-credential'
}
]
});Self-Hosted TURN Server
// Using coturn (open-source TURN server)
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:your-turn-server.com:3478',
username: 'turn-user',
credential: 'turn-password'
}
]
});Best Practices
1. Always include STUN servers: Helps with most connections
2. Have TURN fallback: Essential for production (20-30% of connections need it)
3. Use multiple TURN servers: Redundancy and geographic distribution
4. Monitor connection quality: Track which connection type is used
5. Optimize TURN usage: Only use when necessary (costs money)
Cost Considerations
Complete WebRTC Example
Complete Peer-to-Peer Video Call
Here's a complete example of a WebRTC video call implementation.
HTML
<!DOCTYPE html>
<html>
<head>
<title>WebRTC Video Call</title>
</head>
<body>
<video id="localVideo" autoplay muted></video>
<video id="remoteVideo" autoplay></video>
<button id="startCall">Start Call</button>
<button id="endCall">End Call</button>
</body>
</html>JavaScript
class WebRTCCall {
constructor() {
this.pc = null;
this.localStream = null;
this.signaling = new SignalingClient('ws://signaling-server.com');
this.setupSignaling();
}
async startCall() {
// 1. Get user media
this.localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
// Display local video
document.getElementById('localVideo').srcObject = this.localStream;
// 2. Create peer connection
this.pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turnserver.com:3478',
username: 'user',
credential: 'pass'
}
]
});
// 3. Add local tracks
this.localStream.getTracks().forEach(track => {
this.pc.addTrack(track, this.localStream);
});
// 4. Handle remote tracks
this.pc.ontrack = (event) => {
const [remoteStream] = event.streams;
document.getElementById('remoteVideo').srcObject = remoteStream;
};
// 5. Handle ICE candidates
this.pc.onicecandidate = (event) => {
if (event.candidate) {
this.signaling.send({
type: 'ice-candidate',
candidate: event.candidate
});
}
};
// 6. Create and send offer
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.signaling.send({ type: 'offer', offer });
}
setupSignaling() {
// Handle incoming offer
this.signaling.on('offer', async ({ offer }) => {
if (!this.pc) {
await this.initializeAsAnswerer();
}
await this.pc.setRemoteDescription(offer);
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.signaling.send({ type: 'answer', answer });
});
// Handle incoming answer
this.signaling.on('answer', async ({ answer }) => {
await this.pc.setRemoteDescription(answer);
});
// Handle ICE candidates
this.signaling.on('ice-candidate', async ({ candidate }) => {
await this.pc.addIceCandidate(candidate);
});
}
async initializeAsAnswerer() {
// Get user media
this.localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
document.getElementById('localVideo').srcObject = this.localStream;
// Create peer connection
this.pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
});
// Add local tracks
this.localStream.getTracks().forEach(track => {
this.pc.addTrack(track, this.localStream);
});
// Handle remote tracks
this.pc.ontrack = (event) => {
const [remoteStream] = event.streams;
document.getElementById('remoteVideo').srcObject = remoteStream;
};
// Handle ICE candidates
this.pc.onicecandidate = (event) => {
if (event.candidate) {
this.signaling.send({
type: 'ice-candidate',
candidate: event.candidate
});
}
};
}
endCall() {
// Stop all tracks
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
}
// Close peer connection
if (this.pc) {
this.pc.close();
this.pc = null;
}
// Clear video elements
document.getElementById('localVideo').srcObject = null;
document.getElementById('remoteVideo').srcObject = null;
}
}
// Usage
const call = new WebRTCCall();
document.getElementById('startCall').onclick = () => call.startCall();
document.getElementById('endCall').onclick = () => call.endCall();Error Handling
// Handle connection errors
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'failed') {
// Connection failed, try to reconnect
console.error('Connection failed');
} else if (pc.connectionState === 'disconnected') {
// Connection lost, attempt to reconnect
console.warn('Connection disconnected');
}
};
// Handle ICE connection errors
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
// ICE failed, restart ICE
pc.restartIce();
}
};
// Handle media stream errors
pc.ontrack = (event) => {
event.track.onended = () => {
console.log('Track ended');
};
event.track.onerror = (error) => {
console.error('Track error:', error);
};
};Best Practices
1. Always handle errors: Connection can fail for many reasons
2. Implement reconnection: Network issues are common
3. Monitor connection quality: Track latency, packet loss
4. Clean up resources: Stop tracks, close connections
5. Test with different networks: WiFi, cellular, VPN
6. Use TURN servers in production: Essential for reliability
WebRTC vs Alternatives
| Feature | WebRTC | WebSocket |
|---|---|---|
| Media | β Native audio/video | β Text/binary only |
| Latency | β Very low (P2P) | β Higher (via server) |
| Bandwidth | β Efficient (P2P) | β Server relays all traffic |
| Complexity | β More complex | β Simpler |
| Use Case | Video/audio calls | Real-time data (chat, games) |
| Feature | WebRTC (P2P) | SFU/MCU |
| -------- | -------------- | --------- |
| Scalability | β Limited (2-10 peers) | β Scales to 100+ participants |
| Bandwidth | β Efficient (direct) | β Server relays all traffic |
| Cost | β Lower (no server) | β Higher (server infrastructure) |
| Quality Control | β Limited | β Server can adjust quality |
| Recording | β Client-side only | β Server-side recording |
| Feature | WebRTC | Plugins |
| -------- | -------- | --------- |
| Browser Support | β Native (no plugin) | β Requires plugin installation |
| Security | β Better (sandboxed) | β Plugin vulnerabilities |
| Mobile | β Works on mobile | β Limited mobile support |
| Performance | β Good | β Varies |
Best Practices & Common Pitfalls
Best Practices
1. Always Include STUN/TURN Servers
// Good: Multiple STUN servers + TURN fallback
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{
urls: 'turn:turnserver.com:3478',
username: 'user',
credential: 'pass'
}
]
});2. Handle Connection State Changes
pc.onconnectionstatechange = () => {
switch (pc.connectionState) {
case 'connected':
console.log('Connected!');
break;
case 'disconnected':
console.log('Disconnected, attempting reconnect...');
// Implement reconnection logic
break;
case 'failed':
console.error('Connection failed');
// Restart connection
break;
}
};3. Clean Up Resources
function cleanup() {
// Stop all media tracks
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
}
// Close peer connection
if (pc) {
pc.close();
pc = null;
}
// Clear video elements
localVideo.srcObject = null;
remoteVideo.srcObject = null;
}4. Handle Permissions Gracefully
async function getUserMedia() {
try {
return await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
} catch (error) {
if (error.name === 'NotAllowedError') {
// User denied permission
alert('Please allow camera/microphone access');
} else if (error.name === 'NotFoundError') {
// No camera/mic found
alert('No camera/microphone found');
} else {
console.error('Error accessing media:', error);
}
throw error;
}
}5. Monitor Connection Quality
// Get connection statistics
const stats = await pc.getStats();
stats.forEach(report => {
if (report.type === 'candidate-pair' && report.selected) {
console.log('RTT:', report.currentRoundTripTime);
console.log('Packets sent:', report.packetsSent);
console.log('Packets received:', report.packetsReceived);
}
});Common Pitfalls
1. Not Setting Local Description Before Sending Offer
// β Wrong: Send offer before setting local description
const offer = await pc.createOffer();
signaling.send({ type: 'offer', offer }); // Missing setLocalDescription!
// β
Correct: Set local description first
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send({ type: 'offer', offer });2. Not Handling ICE Candidates Properly
// β Wrong: Only handling first candidate
pc.onicecandidate = (event) => {
if (event.candidate) {
signaling.send({ candidate: event.candidate });
// Missing: Continue listening for more candidates
}
};
// β
Correct: Handle all candidates
pc.onicecandidate = (event) => {
if (event.candidate) {
signaling.send({ candidate: event.candidate });
} else {
console.log('ICE gathering complete');
}
};3. Not Cleaning Up Tracks
// β Wrong: Don't stop tracks
function endCall() {
pc.close(); // Tracks still active!
}
// β
Correct: Stop tracks before closing
function endCall() {
localStream.getTracks().forEach(track => track.stop());
pc.close();
}4. Not Handling Reconnection
// β Wrong: No reconnection logic
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'failed') {
console.error('Connection failed');
// No recovery attempt
}
};
// β
Correct: Implement reconnection
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'failed') {
// Restart ICE
pc.restartIce();
// Or recreate connection
}
};5. Not Testing with Different Networks
// Always test:
// - Same network (LAN)
// - Different networks (internet)
// - Behind NAT/firewall
// - Mobile networks (3G, 4G, 5G)
// - VPN connections