WebRTC Fundamentals
WebRTC (Web Real-Time Communication) is a powerful technology that enables peer-to-peer audio, video, and data communication directly between browsers without requiring plugins or intermediary servers.
What is WebRTC?
WebRTC consists of several key APIs and protocols:
- MediaStream API (getUserMedia): Access camera and microphone
- RTCPeerConnection: Stream audio/video between peers
- RTCDataChannel: Send arbitrary data between peers
- Signaling: Exchange connection information (not part of WebRTC spec)
Key Concept: WebRTC enables direct peer-to-peer communication, meaning data flows directly between browsers without going through a server (after initial connection setup).
Peer-to-Peer Communication
WebRTC communication flow:
1. User A creates an offer (SDP - Session Description Protocol)
2. User A sends the offer to User B via signaling server
3. User B receives the offer and creates an answer
4. User B sends the answer back to User A via signaling server
5. Both users exchange ICE candidates to find the best connection path
6. Direct peer-to-peer connection is established
7. Media streams (audio/video) or data flows directly between peers
Accessing Camera and Microphone
The getUserMedia API allows accessing media devices:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WebRTC Camera Access</title>
</head>
<body>
<video id="localVideo" autoplay playsinline muted></video>
<button id="startBtn">Start Camera</button>
<button id="stopBtn">Stop Camera</button>
<script>
const localVideo = document.getElementById('localVideo');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
let localStream = null;
// Start camera and microphone
startBtn.addEventListener('click', async () => {
try {
// Request access to camera and microphone
localStream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user' // Front camera
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
// Display video stream
localVideo.srcObject = localStream;
console.log('Camera and microphone started');
} catch (error) {
console.error('Error accessing media devices:', error);
alert('Could not access camera/microphone: ' + error.message);
}
});
// Stop all tracks
stopBtn.addEventListener('click', () => {
if (localStream) {
localStream.getTracks().forEach(track => {
track.stop();
console.log('Stopped track:', track.kind);
});
localVideo.srcObject = null;
localStream = null;
}
});
</script>
</body>
</html>
Permission Handling: Browsers require HTTPS for getUserMedia (except localhost). Always handle permission denial gracefully with user-friendly error messages.
Signaling Server
WebRTC requires a signaling server to exchange connection information. Here's a simple WebSocket-based signaling server:
// signaling-server.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
const clients = new Map();
wss.on('connection', (ws) => {
const clientId = generateId();
clients.set(clientId, ws);
console.log(`Client ${clientId} connected`);
// Send client their ID
ws.send(JSON.stringify({
type: 'registered',
clientId: clientId
}));
ws.on('message', (message) => {
const data = JSON.parse(message);
switch (data.type) {
case 'offer':
case 'answer':
case 'ice-candidate':
// Forward to target client
const targetWs = clients.get(data.target);
if (targetWs && targetWs.readyState === WebSocket.OPEN) {
targetWs.send(JSON.stringify({
...data,
from: clientId
}));
}
break;
case 'list-clients':
// Send list of connected clients
ws.send(JSON.stringify({
type: 'clients-list',
clients: Array.from(clients.keys()).filter(id => id !== clientId)
}));
break;
}
});
ws.on('close', () => {
clients.delete(clientId);
console.log(`Client ${clientId} disconnected`);
});
});
function generateId() {
return Math.random().toString(36).substr(2, 9);
}
console.log('Signaling server running on port 8080');
ICE Candidates
ICE (Interactive Connectivity Establishment) finds the best path to connect peers:
// ICE candidate types:
// 1. Host candidate - Direct local IP address
{
candidate: 'candidate:1 1 UDP 2130706431 192.168.1.100 54321 typ host',
sdpMLineIndex: 0,
sdpMid: '0'
}
// 2. Server reflexive candidate - Public IP from STUN server
{
candidate: 'candidate:2 1 UDP 1694498815 203.0.113.1 54321 typ srflx',
sdpMLineIndex: 0,
sdpMid: '0'
}
// 3. Relay candidate - TURN server relay
{
candidate: 'candidate:3 1 UDP 16777215 198.51.100.1 54321 typ relay',
sdpMLineIndex: 0,
sdpMid: '0'
}
STUN and TURN Servers
Network traversal servers help establish connections through firewalls and NAT:
- STUN (Session Traversal Utilities for NAT): Discovers public IP address and port
- TURN (Traversal Using Relays around NAT): Relays traffic when direct connection fails
// Configure ICE servers
const configuration = {
iceServers: [
{
// Google's public STUN server
urls: 'stun:stun.l.google.com:19302'
},
{
// Public STUN servers
urls: [
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302'
]
},
{
// TURN server (requires authentication)
urls: 'turn:turn.example.com:3478',
username: 'user',
credential: 'password'
}
],
iceCandidatePoolSize: 10
};
const peerConnection = new RTCPeerConnection(configuration);
Production Note: Google's STUN servers are free but not guaranteed for production. For critical applications, run your own STUN/TURN servers (e.g., coturn).
RTCPeerConnection Basics
Creating and managing a peer connection:
// Create peer connection
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
const peerConnection = new RTCPeerConnection(configuration);
// Add local stream tracks to connection
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
// Listen for remote stream
peerConnection.ontrack = (event) => {
console.log('Received remote track');
const remoteVideo = document.getElementById('remoteVideo');
if (!remoteVideo.srcObject) {
remoteVideo.srcObject = event.streams[0];
}
};
// Listen for ICE candidates
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
// Send candidate to remote peer via signaling
sendToSignaling({
type: 'ice-candidate',
candidate: event.candidate,
target: remotePeerId
});
}
};
// Listen for connection state changes
peerConnection.onconnectionstatechange = () => {
console.log('Connection state:', peerConnection.connectionState);
switch (peerConnection.connectionState) {
case 'connected':
console.log('Peers connected!');
break;
case 'disconnected':
console.log('Peers disconnected');
break;
case 'failed':
console.log('Connection failed');
break;
case 'closed':
console.log('Connection closed');
break;
}
};
// Listen for ICE connection state
peerConnection.oniceconnectionstatechange = () => {
console.log('ICE state:', peerConnection.iceConnectionState);
};
Creating an Offer
The calling peer creates and sends an offer:
async function createOffer() {
try {
// Create offer
const offer = await peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
// Set local description
await peerConnection.setLocalDescription(offer);
console.log('Created offer:', offer);
// Send offer to remote peer via signaling
sendToSignaling({
type: 'offer',
sdp: offer,
target: remotePeerId
});
} catch (error) {
console.error('Error creating offer:', error);
}
}
Handling an Offer and Creating an Answer
The receiving peer handles the offer and responds with an answer:
async function handleOffer(offer, from) {
try {
// Set remote description from offer
await peerConnection.setRemoteDescription(
new RTCSessionDescription(offer)
);
console.log('Set remote description from offer');
// Create answer
const answer = await peerConnection.createAnswer();
// Set local description
await peerConnection.setLocalDescription(answer);
console.log('Created answer:', answer);
// Send answer back to caller via signaling
sendToSignaling({
type: 'answer',
sdp: answer,
target: from
});
} catch (error) {
console.error('Error handling offer:', error);
}
}
async function handleAnswer(answer) {
try {
// Set remote description from answer
await peerConnection.setRemoteDescription(
new RTCSessionDescription(answer)
);
console.log('Set remote description from answer');
} catch (error) {
console.error('Error handling answer:', error);
}
}
Adding ICE Candidates
Exchange ICE candidates to find the best connection path:
async function handleIceCandidate(candidate) {
try {
await peerConnection.addIceCandidate(
new RTCIceCandidate(candidate)
);
console.log('Added ICE candidate');
} catch (error) {
console.error('Error adding ICE candidate:', error);
}
}
Complete Simple WebRTC Example
// Client-side complete example
const signalingServer = new WebSocket('ws://localhost:8080');
let peerConnection = null;
let localStream = null;
let remotePeerId = null;
signalingServer.onopen = () => {
console.log('Connected to signaling server');
};
signalingServer.onmessage = async (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'registered':
console.log('My ID:', data.clientId);
break;
case 'offer':
remotePeerId = data.from;
await handleOffer(data.sdp, data.from);
break;
case 'answer':
await handleAnswer(data.sdp);
break;
case 'ice-candidate':
await handleIceCandidate(data.candidate);
break;
}
};
function sendToSignaling(message) {
signalingServer.send(JSON.stringify(message));
}
async function startCall(targetId) {
remotePeerId = targetId;
// Get local media
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
document.getElementById('localVideo').srcObject = localStream;
// Create peer connection
peerConnection = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// Add tracks
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
// Handle remote stream
peerConnection.ontrack = (event) => {
document.getElementById('remoteVideo').srcObject = event.streams[0];
};
// Handle ICE candidates
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
sendToSignaling({
type: 'ice-candidate',
candidate: event.candidate,
target: remotePeerId
});
}
};
// Create and send offer
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
sendToSignaling({
type: 'offer',
sdp: offer,
target: targetId
});
}
Exercise: Build a Simple Video Chat
Create a Basic Two-Person Video Chat:
- Set up a signaling server using WebSockets (use the example above)
- Create a web page with local and remote video elements
- Implement getUserMedia to access camera and microphone
- Create RTCPeerConnection and handle the offer/answer flow
- Exchange ICE candidates between peers
- Display connection status (connecting, connected, failed)
- Add basic controls: mute/unmute audio, enable/disable video
- Bonus: Add a "hang up" button to properly close the connection
Summary
- WebRTC enables peer-to-peer audio, video, and data communication
- getUserMedia accesses camera and microphone with user permission
- RTCPeerConnection manages the peer-to-peer connection
- Signaling servers exchange connection information (not part of WebRTC spec)
- ICE candidates help find the best connection path through NAT/firewalls
- STUN servers discover public IP addresses
- TURN servers relay traffic when direct connection fails
- The offer/answer model establishes the connection parameters
- WebRTC requires HTTPS in production (except localhost)