WebRTC
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.
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.
| 1 | // WebRTC connection flow: |
| 2 | |
| 3 | // Step 1: Signaling (via WebSocket/HTTP — not part of WebRTC spec) |
| 4 | Peer 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
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.
| 1 | // Complete peer connection setup |
| 2 | const 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 |
| 13 | async 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 |
| 66 | async 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 |
| 107 | let pc; |
| 108 | const ws = new WebSocket('wss://signaling.example.com'); |
| 109 | |
| 110 | ws.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
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.
| 1 | // Create a reliable, ordered data channel (like TCP) |
| 2 | const dataChannel = pc.createDataChannel('chat', { |
| 3 | ordered: true, |
| 4 | }); |
| 5 | |
| 6 | // Create an unreliable data channel (like UDP) — good for game state |
| 7 | const gameChannel = pc.createDataChannel('game-state', { |
| 8 | ordered: false, |
| 9 | maxRetransmits: 0, // No retransmissions |
| 10 | }); |
| 11 | |
| 12 | // Create a channel with custom settings |
| 13 | const fileChannel = pc.createDataChannel('files', { |
| 14 | ordered: true, |
| 15 | maxPacketLifeTime: 5000, // 5 second timeout |
| 16 | }); |
| 17 | |
| 18 | // Sender |
| 19 | dataChannel.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 |
| 35 | dataChannel.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 | |
| 44 | dataChannel.onclose = () => console.log('Channel closed'); |
| 45 | dataChannel.onerror = (err) => console.error('Channel error:', err); |
| 46 | |
| 47 | // Handle incoming data channels (on the receiving peer) |
| 48 | pc.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 |
| 56 | async 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 |
| 90 | function 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
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.
| 1 | // Get media with quality constraints |
| 2 | async 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 |
| 21 | function 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 |
| 29 | async 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 |
| 56 | async 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 |
| 87 | function 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
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.
| 1 | // Signaling server with WebSocket (Node.js) |
| 2 | const WebSocket = require('ws'); |
| 3 | const wss = new WebSocket.Server({ port: 8081 }); |
| 4 | |
| 5 | const rooms = new Map(); |
| 6 | |
| 7 | wss.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 |
| 75 | class 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
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.
| 1 | // Simulcast — send multiple quality layers |
| 2 | async 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 |
| 18 | const mediasoup = require('mediasoup'); |
| 19 | |
| 20 | async function createWorker() { |
| 21 | const worker = await mediasoup.createWorker({ |
| 22 | rtcMinPort: 10000, |
| 23 | rtcMaxPort: 10100, |
| 24 | }); |
| 25 | return worker; |
| 26 | } |
| 27 | |
| 28 | async 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
WebRTC provides several advanced APIs for recording, statistics, and connection management. These are essential for building production-grade real-time applications.
| 1 | // Record a WebRTC stream |
| 2 | function 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 |
| 27 | async 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 |
| 64 | async 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 |
| 74 | async 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
- 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