|$ curl https://forge-ai.dev/api/markdown?path=docs/web-platform/webrtc
$cat docs/webrtc.md
updated Recently·35 min read·published

WebRTC

WebRTCReal-TimeP2PAdvanced🎯Free Tools
Introduction

WebRTC (Web Real-Time Communication) is a browser API that enables peer-to-peer audio, video, and data sharing between devices without plugins or native apps. It handles the complex networking challenges of NAT traversal, encryption, and codec negotiation automatically.

WebRTC requires a signaling mechanism to establish connections (usually via WebSocket or HTTP), STUN servers to discover public IP addresses, and TURN servers as fallbacks when direct peer-to-peer connections fail. The result is encrypted, low-latency communication between any two browsers.

Architecture Overview

WebRTC connections are established through a multi-step process. Two peers exchange SDP (Session Description Protocol) offers and answers through a signaling server. ICE (Interactive Connectivity Establishment) candidates are gathered to find the best network path between peers.

webrtc-architecture.txt
TEXT
1// WebRTC connection flow:
2
3// Step 1: Signaling (via WebSocket/HTTP — not part of WebRTC spec)
4Peer A Signaling Server Peer B
5 | | |
6 |-- createOffer() ----------------->| |
7 | (SDP Offer) |-- send offer ----------------->|
8 | | |
9 | |<--- createAnswer() -----------|
10 | | (SDP Answer) |
11 |<-- send answer -------------------| |
12 | | |
13 | | |
14 |<---------- ICE candidates ------->|<---------- ICE candidates -----|
15 | (STUN/TURN server responses) | |
16 | | |
17 |============ P2P Connection Established (encrypted) ================|
18 | | |
19 |<================== Audio/Video/Data Stream =======================>|
20
21// ICE candidate gathering process:
22// 1. Host candidates — local network addresses
23// 2. Server reflexive candidates — public IP via STUN
24// 3. Relay candidates — TURN server fallback
25
26// STUN server (discovers public IP):
27// stun:stun.l.google.com:19302
28// stun:stun1.l.google.com:19302
29
30// TURN server (relays traffic when P2P fails):
31// turn:turn.example.com:3478
32// turn:turn.example.com:3478?transport=tcp

info

WebRTC is NAT traversal made easy. Most home routers use NAT, making direct peer-to-peer connections difficult. ICE + STUN handles ~85% of cases. TURN servers relay traffic for the remaining ~15% where direct connection is impossible, at the cost of bandwidth and latency.
Peer Connections

RTCPeerConnection is the core API. It manages the connection lifecycle, ICE candidate gathering, codec negotiation, and encryption. Each peer creates one RTCPeerConnection per remote peer.

peer-connection.js
JavaScript
1// Complete peer connection setup
2const iceServers = [
3 { urls: 'stun:stun.l.google.com:19302' },
4 { urls: 'stun:stun1.l.google.com:19302' },
5 {
6 urls: 'turn:turn.example.com:3478',
7 username: 'user',
8 credential: 'pass',
9 },
10];
11
12// Peer A — creates offer
13async function createOffer(ws) {
14 const pc = new RTCPeerConnection({ iceServers });
15
16 // Add local tracks to the connection
17 const stream = await navigator.mediaDevices.getUserMedia({
18 video: true,
19 audio: true,
20 });
21 stream.getTracks().forEach((track) => {
22 pc.addTrack(track, stream);
23 });
24
25 // Handle incoming tracks from remote peer
26 pc.ontrack = (event) => {
27 const remoteVideo = document.getElementById('remote-video');
28 remoteVideo.srcObject = event.streams[0];
29 };
30
31 // Gather ICE candidates and send to signaling server
32 pc.onicecandidate = (event) => {
33 if (event.candidate) {
34 ws.send(JSON.stringify({
35 type: 'ice-candidate',
36 candidate: event.candidate,
37 }));
38 }
39 };
40
41 // Monitor connection state
42 pc.onconnectionstatechange = () => {
43 console.log('Connection state:', pc.connectionState);
44 if (pc.connectionState === 'failed') {
45 console.error('Connection failed — consider using TURN server');
46 }
47 };
48
49 pc.oniceconnectionstatechange = () => {
50 console.log('ICE state:', pc.iceConnectionState);
51 };
52
53 // Create and send SDP offer
54 const offer = await pc.createOffer();
55 await pc.setLocalDescription(offer);
56
57 ws.send(JSON.stringify({
58 type: 'offer',
59 sdp: pc.localDescription,
60 }));
61
62 return pc;
63}
64
65// Peer B — receives offer, creates answer
66async function handleOffer(ws, offer) {
67 const pc = new RTCPeerConnection({ iceServers });
68
69 // Add local tracks
70 const stream = await navigator.mediaDevices.getUserMedia({
71 video: true,
72 audio: true,
73 });
74 stream.getTracks().forEach((track) => {
75 pc.addTrack(track, stream);
76 });
77
78 // Handle incoming tracks
79 pc.ontrack = (event) => {
80 document.getElementById('remote-video').srcObject = event.streams[0];
81 };
82
83 // ICE candidates
84 pc.onicecandidate = (event) => {
85 if (event.candidate) {
86 ws.send(JSON.stringify({
87 type: 'ice-candidate',
88 candidate: event.candidate,
89 }));
90 }
91 };
92
93 // Set remote description and create answer
94 await pc.setRemoteDescription(new RTCSessionDescription(offer));
95 const answer = await pc.createAnswer();
96 await pc.setLocalDescription(answer);
97
98 ws.send(JSON.stringify({
99 type: 'answer',
100 sdp: pc.localDescription,
101 }));
102
103 return pc;
104}
105
106// Signaling server handler
107let pc;
108const ws = new WebSocket('wss://signaling.example.com');
109
110ws.onmessage = async (event) => {
111 const msg = JSON.parse(event.data);
112
113 switch (msg.type) {
114 case 'offer':
115 pc = await handleOffer(ws, msg.sdp);
116 break;
117 case 'answer':
118 await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
119 break;
120 case 'ice-candidate':
121 await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
122 break;
123 }
124};

warning

WebRTC connections are encrypted by default using DTLS (Datagram Transport Layer Security). However, the signaling channel is NOT encrypted by WebRTC itself — you must use HTTPS/WSS for your signaling server to protect SDP exchanges.
Data Channels

RTCDataChannel enables arbitrary data transfer between peers — text, files, or binary data. It supports ordered/unordered delivery, reliable/unreliable modes, and message chunking for large payloads.

data-channels.js
JavaScript
1// Create a reliable, ordered data channel (like TCP)
2const dataChannel = pc.createDataChannel('chat', {
3 ordered: true,
4});
5
6// Create an unreliable data channel (like UDP) — good for game state
7const gameChannel = pc.createDataChannel('game-state', {
8 ordered: false,
9 maxRetransmits: 0, // No retransmissions
10});
11
12// Create a channel with custom settings
13const fileChannel = pc.createDataChannel('files', {
14 ordered: true,
15 maxPacketLifeTime: 5000, // 5 second timeout
16});
17
18// Sender
19dataChannel.onopen = () => {
20 // Send text
21 dataChannel.send('Hello, peer!');
22
23 // Send binary data
24 const encoder = new TextEncoder();
25 dataChannel.send(encoder.encode('Binary message'));
26
27 // Send structured data
28 dataChannel.send(JSON.stringify({
29 type: 'update',
30 data: { x: 100, y: 200 },
31 }));
32};
33
34// Receiver
35dataChannel.onmessage = (event) => {
36 if (typeof event.data === 'string') {
37 console.log('Text:', event.data);
38 } else if (event.data instanceof ArrayBuffer) {
39 const decoder = new TextDecoder();
40 console.log('Binary:', decoder.decode(event.data));
41 }
42};
43
44dataChannel.onclose = () => console.log('Channel closed');
45dataChannel.onerror = (err) => console.error('Channel error:', err);
46
47// Handle incoming data channels (on the receiving peer)
48pc.ondatachannel = (event) => {
49 const channel = event.channel;
50 channel.onmessage = (e) => {
51 console.log('Received on', channel.name, ':', e.data);
52 };
53};
54
55// File transfer over data channel
56async function sendFile(file, channel) {
57 const chunkSize = 16384; // 16KB chunks
58 const fileReader = new FileReader();
59 let offset = 0;
60
61 // Send file metadata first
62 channel.send(JSON.stringify({
63 type: 'file-meta',
64 name: file.name,
65 size: file.size,
66 mimeType: file.type,
67 }));
68
69 fileReader.onload = (event) => {
70 const data = event.target.result;
71 channel.send(data);
72 offset += data.byteLength;
73
74 if (offset < file.size) {
75 readSlice(offset);
76 } else {
77 channel.send(JSON.stringify({ type: 'file-complete' }));
78 }
79 };
80
81 function readSlice(start) {
82 const slice = file.slice(start, start + chunkSize);
83 fileReader.readAsArrayBuffer(slice);
84 }
85
86 readSlice(0);
87}
88
89// Receive files
90function setupFileReceiver(channel) {
91 let receivedBuffers = [];
92 let fileMeta = null;
93
94 channel.onmessage = (event) => {
95 const data = event.data;
96
97 if (typeof data === 'string') {
98 const msg = JSON.parse(data);
99 if (msg.type === 'file-meta') {
100 fileMeta = msg;
101 receivedBuffers = [];
102 } else if (msg.type === 'file-complete') {
103 const blob = new Blob(receivedBuffers, { type: fileMeta.mimeType });
104 const url = URL.createObjectURL(blob);
105 const a = document.createElement('a');
106 a.href = url;
107 a.download = fileMeta.name;
108 a.click();
109 URL.revokeObjectURL(url);
110 }
111 } else {
112 receivedBuffers.push(data);
113 }
114 };
115}
🔥

pro tip

For game state or video streaming, use unreliable channels (maxRetransmits: 0) to avoid stale data. For chat or file transfers, use reliable ordered channels. Data channels use SCTP under the hood, which runs on top of DTLS.
Audio & Video Streaming

WebRTC integrates with the MediaDevices API to capture camera and microphone input, then streams it to remote peers. You can control quality, resolution, frame rate, and apply constraints to adapt to network conditions.

media-streams.js
JavaScript
1// Get media with quality constraints
2async function getMedia() {
3 const stream = await navigator.mediaDevices.getUserMedia({
4 video: {
5 width: { ideal: 1280, max: 1920 },
6 height: { ideal: 720, max: 1080 },
7 frameRate: { ideal: 30, max: 60 },
8 facingMode: 'user', // 'environment' for rear camera
9 },
10 audio: {
11 echoCancellation: true,
12 noiseSuppression: true,
13 autoGainControl: true,
14 sampleRate: 48000,
15 },
16 });
17 return stream;
18}
19
20// Add tracks to peer connection
21function addMediaTracks(pc, stream) {
22 const senders = stream.getTracks().map((track) => {
23 return pc.addTrack(track, stream);
24 });
25 return senders;
26}
27
28// Dynamically change video quality based on bandwidth
29async function adaptQuality(pc, senders, bandwidth) {
30 const sender = senders.find((s) => s.track?.kind === 'video');
31 if (!sender) return;
32
33 const params = sender.getParameters();
34 if (!params.encodings) {
35 params.encodings = [{}];
36 }
37
38 if (bandwidth > 2500000) {
39 // High bandwidth — full quality
40 params.encodings[0].maxBitrate = 2500000;
41 params.encodings[0].maxFramerate = 30;
42 } else if (bandwidth > 1000000) {
43 // Medium bandwidth
44 params.encodings[0].maxBitrate = 1000000;
45 params.encodings[0].maxFramerate = 24;
46 } else {
47 // Low bandwidth — reduce quality
48 params.encodings[0].maxBitrate = 300000;
49 params.encodings[0].maxFramerate = 15;
50 }
51
52 await sender.setParameters(params);
53}
54
55// Screen sharing
56async function startScreenShare(pc) {
57 try {
58 const screenStream = await navigator.mediaDevices.getDisplayMedia({
59 video: {
60 cursor: 'always',
61 width: { ideal: 1920 },
62 height: { ideal: 1080 },
63 },
64 audio: false,
65 });
66
67 const videoTrack = screenStream.getVideoTracks()[0];
68
69 // Replace video track in existing connection
70 const sender = pc.getSenders().find((s) => s.track?.kind === 'video');
71 await sender.replaceTrack(videoTrack);
72
73 // Handle user stopping screen share
74 videoTrack.onended = () => {
75 // Revert to camera
76 const cameraTrack = localStream.getVideoTracks()[0];
77 sender.replaceTrack(cameraTrack);
78 };
79
80 return screenStream;
81 } catch (err) {
82 console.error('Screen share failed:', err);
83 }
84}
85
86// Monitor connection quality
87function monitorQuality(pc) {
88 setInterval(async () => {
89 const stats = await pc.getStats();
90 stats.forEach((report) => {
91 if (report.type === 'inbound-rtp' && report.kind === 'video') {
92 console.log({
93 packetsLost: report.packetsLost,
94 jitter: report.jitter,
95 frameWidth: report.frameWidth,
96 frameHeight: report.frameHeight,
97 framesPerSecond: report.framesPerSecond,
98 bytesReceived: report.bytesReceived,
99 });
100 }
101 if (report.type === 'candidate-pair' && report.state === 'succeeded') {
102 console.log('RTT:', report.currentRoundTripTime * 1000, 'ms');
103 console.log('Bandwidth:', report.availableOutgoingBitrate, 'bps');
104 }
105 });
106 }, 2000);
107}

best practice

Always handle getUserMedia errors gracefully. Users may deny camera/microphone permission, or the device may be unavailable. Use try/catch and check error.name for NotAllowedError, NotFoundError, or NotReadableError to show appropriate messages.
Signaling Server

WebRTC does not define a signaling protocol — you implement your own. The signaling server exchanges SDP offers/answers and ICE candidates between peers. It is only needed during connection setup and can disconnect after the P2P link is established.

signaling-server.js
JavaScript
1// Signaling server with WebSocket (Node.js)
2const WebSocket = require('ws');
3const wss = new WebSocket.Server({ port: 8081 });
4
5const rooms = new Map();
6
7wss.on('connection', (ws) => {
8 let currentRoom = null;
9
10 ws.on('message', (raw) => {
11 const msg = JSON.parse(raw);
12
13 switch (msg.type) {
14 case 'join':
15 currentRoom = msg.room;
16 if (!rooms.has(msg.room)) rooms.set(msg.room, new Set());
17 rooms.get(msg.room).add(ws);
18
19 // Notify existing users
20 rooms.get(msg.room).forEach((peer) => {
21 if (peer !== ws) {
22 peer.send(JSON.stringify({
23 type: 'peer-joined',
24 peerId: msg.peerId,
25 }));
26 }
27 });
28 break;
29
30 case 'offer':
31 case 'answer':
32 case 'ice-candidate':
33 // Forward to target peer
34 const room = rooms.get(currentRoom);
35 if (room) {
36 room.forEach((peer) => {
37 if (peer !== ws && peer.readyState === WebSocket.OPEN) {
38 peer.send(JSON.stringify({
39 ...msg,
40 from: msg.from,
41 }));
42 }
43 });
44 }
45 break;
46
47 case 'leave':
48 if (currentRoom && rooms.has(currentRoom)) {
49 rooms.get(currentRoom).delete(ws);
50 rooms.get(currentRoom).forEach((peer) => {
51 peer.send(JSON.stringify({
52 type: 'peer-left',
53 peerId: msg.peerId,
54 }));
55 });
56 if (rooms.get(currentRoom).size === 0) {
57 rooms.delete(currentRoom);
58 }
59 }
60 break;
61 }
62 });
63
64 ws.on('close', () => {
65 if (currentRoom && rooms.has(currentRoom)) {
66 rooms.get(currentRoom).delete(ws);
67 if (rooms.get(currentRoom).size === 0) {
68 rooms.delete(currentRoom);
69 }
70 }
71 });
72});
73
74// Client-side signaling helper
75class SignalingClient {
76 constructor(url, room) {
77 this.ws = new WebSocket(url);
78 this.room = room;
79 this.handlers = {};
80
81 this.ws.onmessage = (event) => {
82 const msg = JSON.parse(event.data);
83 this.handlers[msg.type]?.(msg);
84 };
85
86 this.ws.onopen = () => {
87 this.send({ type: 'join', room: this.room });
88 };
89 }
90
91 send(msg) {
92 this.ws.send(JSON.stringify(msg));
93 }
94
95 on(type, handler) {
96 this.handlers[type] = handler;
97 }
98}

best practice

Keep the signaling server stateless and minimal — it only needs to relay messages between peers. After the P2P connection is established, the signaling server is no longer in the data path. Use rooms or session IDs to route messages correctly.
Simulcast & SFU Architecture

For group video calls, a mesh topology (every peer connected to every other) does not scale. Selective Forwarding Units (SFUs) forward a single stream to multiple receivers. Simulcast sends multiple quality layers so the SFU can select the appropriate quality for each receiver.

simulcast-sfu.js
JavaScript
1// Simulcast — send multiple quality layers
2async function setupSimulcast(pc, stream) {
3 const videoTrack = stream.getVideoTracks()[0];
4 const sender = pc.addTrack(videoTrack, stream);
5
6 // Request simulcast with three layers
7 const params = sender.getParameters();
8 params.encodings = [
9 { rid: 'high', maxBitrate: 1500000, maxFramerate: 30 },
10 { rid: 'mid', maxBitrate: 500000, maxFramerate: 15 },
11 { rid: 'low', maxBitrate: 150000, maxFramerate: 7 },
12 ];
13 await sender.setParameters(params);
14}
15
16// Server-side SFU (conceptual using mediasoup)
17// mediasoup handles the SFU logic — you configure transports and producers
18const mediasoup = require('mediasoup');
19
20async function createWorker() {
21 const worker = await mediasoup.createWorker({
22 rtcMinPort: 10000,
23 rtcMaxPort: 10100,
24 });
25 return worker;
26}
27
28async function createRouter(worker) {
29 const router = await worker.createRouter({
30 mediaCodecs: [
31 {
32 kind: 'audio',
33 mimeType: 'audio/opus',
34 clockRate: 48000,
35 channels: 2,
36 },
37 {
38 kind: 'video',
39 mimeType: 'video/VP8',
40 clockRate: 90000,
41 parameters: {
42 'x-google-start-bitrate': 1000,
43 },
44 },
45 {
46 kind: 'video',
47 mimeType: 'video/VP9',
48 clockRate: 90000,
49 },
50 {
51 kind: 'video',
52 mimeType: 'video/H264',
53 clockRate: 90000,
54 parameters: {
55 'packetization-mode': 1,
56 'profile-level-id': '42e01f',
57 },
58 },
59 ],
60 });
61 return router;
62}
63
64// SVC (Scalable Video Coding) — alternative to simulcast
65// VP9 and AV1 support temporal and spatial scalability
66// Single stream, decoder picks the right quality layer
67// More bandwidth efficient than simulcast but higher CPU cost

info

For 1-to-1 calls, a direct P2P connection works well. For group calls (3+), use an SFU like mediasoup, Janus, or LiveKit. SFUs are more efficient because each participant sends one stream that the server forwards to others, instead of sending individual streams to each peer.
Advanced Features

WebRTC provides several advanced APIs for recording, statistics, and connection management. These are essential for building production-grade real-time applications.

webrtc-advanced.js
JavaScript
1// Record a WebRTC stream
2function recordStream(stream) {
3 const mediaRecorder = new MediaRecorder(stream, {
4 mimeType: 'video/webm;codecs=vp9,opus',
5 videoBitsPerSecond: 2500000,
6 });
7
8 const chunks = [];
9 mediaRecorder.ondataavailable = (e) => {
10 if (e.data.size > 0) chunks.push(e.data);
11 };
12
13 mediaRecorder.onstop = () => {
14 const blob = new Blob(chunks, { type: 'video/webm' });
15 const url = URL.createObjectURL(blob);
16 const a = document.createElement('a');
17 a.href = url;
18 a.download = 'recording.webm';
19 a.click();
20 };
21
22 mediaRecorder.start(1000); // Collect data every second
23 return mediaRecorder;
24}
25
26// Get detailed connection statistics
27async function getConnectionStats(pc) {
28 const stats = await pc.getStats();
29 const report = {};
30
31 stats.forEach((entry) => {
32 if (entry.type === 'inbound-rtp' && entry.kind === 'video') {
33 report.inbound = {
34 resolution: entry.frameWidth + 'x' + entry.frameHeight,
35 fps: entry.framesPerSecond,
36 packetsLost: entry.packetsLost,
37 jitter: entry.jitter,
38 bytesReceived: entry.bytesReceived,
39 };
40 }
41
42 if (entry.type === 'outbound-rtp' && entry.kind === 'video') {
43 report.outbound = {
44 resolution: entry.frameWidth + 'x' + entry.frameHeight,
45 fps: entry.framesPerSecond,
46 bytesSent: entry.bytesSent,
47 bitrate: calculateBitrate(entry),
48 };
49 }
50
51 if (entry.type === 'candidate-pair' && entry.state === 'succeeded') {
52 report.connection = {
53 rtt: entry.currentRoundTripTime * 1000,
54 availableBandwidth: entry.availableOutgoingBitrate,
55 state: entry.state,
56 };
57 }
58 });
59
60 return report;
61}
62
63// ICE restart — recover from network changes
64async function restartICE(pc, ws) {
65 const offer = await pc.createOffer({ iceRestart: true });
66 await pc.setLocalDescription(offer);
67 ws.send(JSON.stringify({
68 type: 'offer',
69 sdp: pc.localDescription,
70 }));
71}
72
73// Insertable Streams (Encoded Transform) — process frames
74async function addVideoProcessor(pc, stream) {
75 const [track] = stream.getVideoTracks();
76 const sender = pc.addTrack(track, stream);
77
78 if (sender.createEncodedStreams) {
79 const { readable, writable } = sender.createEncodedStreams();
80 const transformStream = new TransformStream({
81 transform(frame, controller) {
82 // Process encoded frame (e.g., add watermark, crop)
83 // Frame is an RTCEncodedVideoFrame
84 controller.enqueue(frame);
85 },
86 });
87 readable.pipeThrough(transformStream).pipeTo(writable);
88 }
89}

best practice

Use pc.getStats() to monitor connection quality in real-time and adapt media quality accordingly. ICE restart (iceRestart: true) is the standard way to recover from network changes like switching from WiFi to cellular.
Key Takeaways
  • WebRTC enables peer-to-peer audio, video, and data without plugins
  • You must build your own signaling server — WebRTC does not define one
  • STUN servers discover public IPs; TURN servers relay traffic as fallback
  • Data channels support reliable/unreliable modes for different use cases
  • For group calls, use an SFU (mediasoup, LiveKit) instead of mesh topology
  • Simulcast sends multiple quality layers for adaptive streaming
  • Always handle getUserMedia errors and provide graceful fallbacks