WebSockets & Real-Time Apps

WebRTC Fundamentals

18 min Lesson 19 of 35

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:
  1. Set up a signaling server using WebSockets (use the example above)
  2. Create a web page with local and remote video elements
  3. Implement getUserMedia to access camera and microphone
  4. Create RTCPeerConnection and handle the offer/answer flow
  5. Exchange ICE candidates between peers
  6. Display connection status (connecting, connected, failed)
  7. Add basic controls: mute/unmute audio, enable/disable video
  8. 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)