Web Real-Time Communication
Purpose
Build real-time communication systems: WebRTC signaling, media server architecture (SFU/MCU), TURN/STUN infrastructure, live streaming, and real-time data channels.
Agent Protocol
Trigger
User mentions WebRTC, real-time video/audio, media streaming, SFU, MCU, signaling server, TURN/STUN, live streaming, media server, coturn, or real-time communication infrastructure.
Input Context
- Use case (video call, live streaming, conferencing, data channel)
- Expected participant count (1-1, small group <10, large group >10)
- Network conditions (NAT/firewall, mobile, enterprise)
- Media types (audio, video, screen share, data channel)
- Deployment model (self-hosted, hybrid, cloud)
- Latency requirements (real-time, near-real-time, low-latency streaming)
Output Artifact
Architecture design with signaling protocol, media server topology, TURN/STUN config, client integration code, and deployment infrastructure.
Response Format
No preamble. No postamble. No explanations. No filler/hedging/transitions. Strip articles a/an/the where unambiguous. Compress output.
Completion Criteria
Max Response Length
4096 tokens
Architecture Decision Trees
Topology Selection
| Criterion |
Mesh (P2P) |
SFU (Selective Forwarding) |
MCU (Multipoint Control) |
| Participants |
2-4 |
4-100+ |
4-50 |
| Server bandwidth |
None |
O(n) — one uplink per peer |
O(1) — single mixed stream |
| Client bandwidth |
O(n^2) — send to all peers |
O(1) — send once, receive n-1 |
O(1) — single stream |
| Latency |
Lowest |
Low (no mixing) |
Higher (mixing delay) |
| CPU cost |
Client-side |
Server relays |
Server transcodes |
| Complexity |
Simplest |
Moderate |
High |
| Fallback |
STUN only |
STUN + TURN |
STUN + TURN |
Decision: Always prefer SFU for multi-party. Mesh for 1-1 calls. MCU only when legacy client compatibility required.
Signaling Protocol Decision
| Criterion |
WebSocket |
HTTP Long Polling |
SSE |
| Latency |
<50ms |
200-500ms |
100-300ms |
| Bidirectional |
Yes |
Yes |
Server→Client only |
| NAT Traversal |
Native ws:// wss:// |
HTTP (always works) |
HTTP (always works) |
| Browser Support |
All modern |
All |
All (except IE) |
| Connection overhead |
Upgrade handshake |
New request per poll |
Persistent connection |
| Reconnection |
Built-in (some libs) |
Stateless |
Built-in (EventSource) |
Decision: WebSocket for production signaling. SSE for one-way broadcast. HTTP polling as last resort for restrictive networks.
Codec Selection
| Codec |
Bitrate |
Quality |
Licensing |
Browser Support |
| VP8 |
200-1500 kbps |
Good |
Royalty-free |
Chrome, Firefox, Safari 14.1+ |
| VP9 |
150-800 kbps |
Better |
Royalty-free |
Chrome, Firefox |
| H.264 |
200-1500 kbps |
Excellent |
Patent-encumbered |
Chrome, Firefox, Safari, Edge |
| AV1 |
100-500 kbps |
Best |
Royalty-free |
Chrome (limited) |
Decision: H.264 for widest compatibility. VP9/AV1 for bandwidth-constrained environments.
Workflow
WebRTC Architecture
Peer A → Signaling Server ← Peer B
| |
↓ ↓
STUN/TURN Server ← → ICE Negotiation ← → STUN/TURN Server
| |
↓ ↓
└─────── Media via SRTP/SCTP ────────┘
Signaling Server Patterns
| Protocol |
When to Use |
Example |
| WebSocket |
Low latency, bidirectional |
Socket.io, ws |
| HTTP Long Polling |
Simple, no WebSocket support |
REST endpoints |
| Server-Sent Events |
One-way server→client |
Event notifications |
Media Server Architecture
| Architecture |
Description |
Pros |
Cons |
| Mesh (P2P) |
Every peer connects to every other |
No server cost |
Bandwidth scales O(n^2) |
| MCU |
Server mixes all streams into one |
Low client bandwidth |
Single point of failure |
| SFU |
Server relays streams, selective forwarding |
Scalable, flexible |
Higher server bandwidth |
SFU (Selective Forwarding Unit) - Recommended
- Each peer sends one stream to SFU
- SFU forwards to all other peers
- Peers can choose which streams to receive
- Scales to hundreds of participants
TURN/STUN Server Setup
| Component |
Purpose |
Service |
| STUN |
Discover public IP/port for NAT traversal |
coturn (self-hosted), Google STUN |
| TURN |
Relay media when P2P fails (NAT/firewall) |
coturn (self-hosted), Twilio Network Traversal |
Implementation Patterns
Pattern: Signaling with WebSocket (Node.js)
// server/signaling.ts
import { WebSocketServer, WebSocket } from 'ws';
interface SignalingMessage {
type: 'offer' | 'answer' | 'ice-candidate' | 'join' | 'leave' | 'room-info';
roomId: string;
senderId: string;
payload: unknown;
}
const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map<string, Map<string, WebSocket>>();
wss.on('connection', (ws) => {
let userId: string;
let currentRoom: string;
ws.on('message', (data) => {
const msg: SignalingMessage = JSON.parse(data.toString());
switch (msg.type) {
case 'join': {
userId = msg.senderId;
currentRoom = msg.roomId;
if (!rooms.has(msg.roomId)) rooms.set(msg.roomId, new Map());
rooms.get(msg.roomId)!.set(userId, ws);
broadcastToRoom(msg.roomId, { type: 'room-info', roomId: msg.roomId, senderId: 'system', payload: { peers: Array.from(rooms.get(msg.roomId)!.keys()) } }, userId);
break;
}
case 'offer':
case 'answer':
case 'ice-candidate': {
const targetPeer = rooms.get(msg.roomId)?.get(msg.payload.targetId);
if (targetPeer?.readyState === WebSocket.OPEN) targetPeer.send(JSON.stringify(msg));
break;
}
case 'leave': {
rooms.get(msg.roomId)?.delete(userId);
broadcastToRoom(msg.roomId, { type: 'leave', roomId: msg.roomId, senderId: userId, payload: {} }, userId);
}
}
});
ws.on('close', () => {
rooms.get(currentRoom)?.delete(userId);
if (rooms.get(currentRoom)?.size === 0) rooms.delete(currentRoom);
broadcastToRoom(currentRoom, { type: 'leave', roomId: currentRoom, senderId: userId, payload: {} }, userId);
});
});
function broadcastToRoom(roomId: string, msg: SignalingMessage, excludeId?: string) {
const room = rooms.get(roomId);
if (!room) return;
for (const [id, ws] of room) {
if (id !== excludeId && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
}
Pattern: Client-Side WebRTC Peer Connection
// client/webrtc.ts
export class WebRTCClient {
private pc: RTCPeerConnection | null = null;
private signaling: WebSocket;
private localStream: MediaStream | null = null;
private remoteStream = new MediaStream();
constructor(serverUrl: string, private userId: string, private roomId: string) {
this.signaling = new WebSocket(serverUrl);
this.setupSignaling();
}
private setupSignaling() {
this.signaling.onopen = () => {
this.signaling.send(JSON.stringify({ type: 'join', roomId: this.roomId, senderId: this.userId, payload: {} }));
};
this.signaling.onmessage = async (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'offer': await this.handleOffer(msg); break;
case 'answer': await this.pc!.setRemoteDescription(new RTCSessionDescription(msg.payload)); break;
case 'ice-candidate': await this.pc!.addIceCandidate(new RTCIceCandidate(msg.payload)); break;
}
};
}
async startCall(constraints: MediaStreamConstraints = { audio: true, video: true }) {
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
this.pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' },
],
});
this.localStream.getTracks().forEach(t => this.pc!.addTrack(t, this.localStream!));
this.pc.ontrack = (event) => { event.streams[0].getTracks().forEach(t => this.remoteStream.addTrack(t)); };
this.pc.onicecandidate = (event) => {
if (event.candidate) this.signaling.send(JSON.stringify({ type: 'ice-candidate', roomId: this.roomId, senderId: this.userId, payload: { targetId: 'peer-id', candidate: event.candidate } }));
};
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.signaling.send(JSON.stringify({ type: 'offer', roomId: this.roomId, senderId: this.userId, payload: { targetId: 'peer-id', sdp: offer } }));
}
private async handleOffer(msg: any) {
this.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
this.pc.ontrack = (event) => { event.streams[0].getTracks().forEach(t => this.remoteStream.addTrack(t)); };
this.pc.onicecandidate = (event) => {
if (event.candidate) this.signaling.send(JSON.stringify({ type: 'ice-candidate', roomId: this.roomId, senderId: this.userId, payload: { targetId: msg.senderId, candidate: event.candidate } }));
};
await this.pc.setRemoteDescription(new RTCSessionDescription(msg.payload.sdp));
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.signaling.send(JSON.stringify({ type: 'answer', roomId: this.roomId, senderId: this.userId, payload: { targetId: msg.senderId, sdp: answer } }));
}
}
Pattern: SFU Selective Forwarding (mediasoup)
// sfu-server.ts
import * as mediasoup from 'mediasoup';
import { WebSocketServer } from 'ws';
async function createSfu() {
const worker = await mediasoup.createWorker();
const router = await worker.createRouter({
mediaCodecs: [
{ kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2 },
{ kind: 'video', mimeType: 'video/VP8', clockRate: 90000 },
{ kind: 'video', mimeType: 'video/H264', clockRate: 90000, parameters: { 'level-asymmetry-allowed': 1, 'packetization-mode': 1, 'profile-level-id': '42e01f' } },
],
});
const wss = new WebSocketServer({ port: 8081 });
const peers = new Map<string, { transport: mediasoup.types.WebRtcTransport; producer: mediasoup.types.Producer | null }>();
wss.on('connection', (ws) => {
ws.on('message', async (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'create-transport') {
const transport = await router.createWebRtcTransport({
listenIps: [{ ip: '0.0.0.0', announcedIp: process.env.PUBLIC_IP }],
enableUdp: true, enableTcp: true, preferUdp: true,
initialAvailableOutgoingBitrate: 1000000,
});
peers.set(msg.peerId, { transport, producer: null });
ws.send(JSON.stringify({ type: 'transport-created', id: transport.id, iceParameters: transport.iceParameters, iceCandidates: transport.iceCandidates, dtlsParameters: transport.dtlsParameters }));
}
if (msg.type === 'connect-transport') {
await peers.get(msg.peerId)!.transport.connect({ dtlsParameters: msg.dtlsParameters });
ws.send(JSON.stringify({ type: 'transport-connected' }));
}
if (msg.type === 'produce') {
const peer = peers.get(msg.peerId)!;
const producer = await peer.transport.produce({ kind: msg.kind, rtpParameters: msg.rtpParameters });
peer.producer = producer;
// Forward to all other peers
for (const [id, p] of peers) {
if (id !== msg.peerId && p.transport) {
const consumer = await p.transport.consume({ producerId: producer.id, rtpCapabilities: router.rtpCapabilities });
ws.send(JSON.stringify({ type: 'new-consumer', peerId: id, producerId: producer.id, id: consumer.id, kind: consumer.kind, rtpParameters: consumer.rtpParameters }));
}
}
ws.send(JSON.stringify({ type: 'produced', id: producer.id }));
}
});
});
}
Pattern: TURN Server with coturn
# docker-compose.yml
version: '3.8'
services:
coturn:
image: coturn/coturn:latest
network_mode: host
command: >
-n --log-file=stdout
--min-port=49152 --max-port=65535
--fingerprint --lt-cred-mech
--realm=example.com
--user=appuser:securepassword
--external-ip=YOUR_PUBLIC_IP
ports:
- "3478:3478/udp"
- "3478:3478/tcp"
- "5349:5349/tcp"
- "49152-65535:49152-65535/udp"
// TURN credential generation (time-limited)
import crypto from 'crypto';
function generateTurnCredentials(sharedSecret: string, username: string, ttl = 86400): { username: string; credential: string } {
const timestamp = Math.floor(Date.now() / 1000) + ttl;
const turnUser = `${timestamp}:${username}`;
const hmac = crypto.createHmac('sha1', sharedSecret).update(turnUser).digest('base64');
return { username: turnUser, credential: hmac };
}
Production Considerations
Scalability
- SFU horizontal scaling: use Redis pub/sub to share peer state across signaling server instances
- Media servers are CPU-bound — monitor packet loss and jitter; scale by concurrent rooms
- TURN bandwidth: budget 2-5 Mbps per active media stream; TURN egress costs dominate
- WebSocket signaling: one connection per peer; plan for 10K+ concurrent connections per node
Deployment
- Separate signaling and media planes — signaling can scale independently from media
- Place TURN servers near users (edge locations) to minimize relay latency
- Use Kubernetes headless services for WebSocket signaling with session affinity
- Monitor: ICE failures, TURN bandwidth, packet loss, jitter, round-trip time
Monitoring
- Key metrics: ICE connection time, call success rate, media bitrate, packet loss, jitter buffer delay
- Alerts: elevated ICE failure rate >5%, TURN bandwidth >80% capacity, signaling latency >200ms
- Logging: structured JSON logs for all signaling messages (type, roomId, peerId, duration)
Anti-Patterns
| Anti-Pattern |
Why |
Fix |
| Relying on P2P mesh for >4 participants |
Bandwidth O(n^2) kills clients |
Use SFU for any multi-party |
| Hardcoded STUN/TURN URLs |
Rotate credentials; no auth on STUN |
Use credential-generating TURN with time-limited tokens |
| No reconnection logic |
WebRTC fails on network change; no recovery |
Implement ICE restart + signaling reconnection |
| Sending raw RTP without congestion control |
Network collapse under load |
Use WebRTC built-in GCC or SCream congestion control |
| Single signaling server |
SPOF for entire system |
Load balance signaling; use Redis for room state |
| No simulcast/SVC encoding |
All receive same quality regardless of bandwidth |
VP9 SVC or VP8 simulcast with 3 spatial layers |
| Blocking TURN ports |
Users behind symmetric NAT can't connect |
Document required ports: 3478 (TURN), 49152-65535 (media) |
Security Considerations
- Always use WSS (WebSocket Secure) and TURN over TLS (5349) — never plain WS/STUN
- TURN credentials: time-limited HMAC, never static passwords; rotate shared secret weekly
- Media encryption: SRTP with DTLS-SRTP key exchange (mandatory in WebRTC)
- Signaling authentication: verify JWT/token before allowing room join; reject unauthenticated offers
- Room access control: token must include roomId; validate on every signaling message
- Rate limit signaling messages per peer (e.g., 50/s) to prevent DoS via offer flooding
- Don't expose internal IPs via ICE candidates — use mDNS ICE candidate in browsers (private IPs hidden)
- Screen sharing: implement user consent dialog; never auto-share
Testing Strategies
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebSocket } from 'ws';
describe('WebRTC Signaling Server', () => {
let ws: WebSocket;
beforeAll(() => { ws = new WebSocket('ws://localhost:8080'); });
afterAll(() => ws.close());
it('handles room join and broadcasts peer list', (done) => {
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'room-info') {
expect(msg.payload.peers).toContain('peer-a');
done();
}
});
ws.send(JSON.stringify({ type: 'join', roomId: 'test-room', senderId: 'peer-a', payload: {} }));
});
it('relays offers between peers', (done) => {
const ws2 = new WebSocket('ws://localhost:8080');
ws2.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'offer') {
expect(msg.payload.sdp.type).toBe('offer');
ws2.close();
done();
}
});
ws2.on('open', () => {
ws2.send(JSON.stringify({ type: 'join', roomId: 'test-room', senderId: 'peer-b', payload: {} }));
ws.send(JSON.stringify({ type: 'offer', roomId: 'test-room', senderId: 'peer-a', payload: { targetId: 'peer-b', sdp: { type: 'offer', sdp: 'v=0...' } } }));
});
});
});
- Test ICE connectivity across simulated network conditions (packet loss, latency, NAT)
- Use test WebRTC peers (e.g., puppeteer with Chrome) for E2E media tests
- Load test signaling server with 1000+ concurrent connections
- Validate TURN allocation and deallocation under load
- Run connectivity matrix: test all combinations of client networks (public, NAT, symmetric NAT)
Rules
- SFU for multi-party calls, P2P mesh for 1-1 only. Never use MCU without transcoding requirement.
- TURN credentials must be time-limited (max 24h TTL). Never use static passwords.
- Signaling must use WSS (not WS) in production. Certificates on first byte.
- Always implement ICE restart on connection loss. Reconnection is mandatory, not optional.
- Media codec negotiation: prefer H.264 for compatibility, VP9/AV1 for quality-per-bitrate.
- Bandwidth estimation must be enabled (REMBB or TWCC). Never send without congestion control.
- Monitor: ICE failures, call duration, packet loss, jitter, RTT per peer.
- Recording: don't record from client-side — record from SFU (server-side) for sync quality.
- Simulcast for multi-party: encode 3 layers (low/medium/high) so SFU can adapt per receiver.
Metrics & SLAs
| Metric |
Target |
Critical |
| ICE connection time |
<3s |
>10s |
| Call success rate |
>98% |
<95% |
| End-to-end latency (audio) |
<150ms |
>400ms |
| End-to-end latency (video) |
<300ms |
>500ms |
| Packet loss |
<1% |
>5% |
| Jitter |
<30ms |
>80ms |
| TURN relay bandwidth |
<70% capacity |
>90% capacity |
| Signaling latency |
<100ms |
>500ms |
Handoff
backend/universal/websocket-patterns — WebSocket fundamentals and optimizations
backend/universal/data-streaming — Real-time data streaming patterns
backend/universal/load-testing — Load testing signaling and media infrastructure
security/network — TURN/STUN firewall and network security
Edge Cases
- Symmetric NAT + firewall: TURN relay required; test with TURN-only clients before launch
- Mobile network handoff: ICE restart on IP change; implement with
connectionstatechange listener
- Browser tab backgrounding: Chrome throttles timers; use
AudioContext timer for audio sync
- Multi-radio (WiFi + cellular): ICE may prefer wrong interface; set
iceTransportPolicy: 'relay' for critical sessions
- Simulcast with bandwidth drop: SFU should signal layer switch via
RTCRtpSender.setParameters dynamically
Performance Optimization
Bandwidth & Bitrate Management
- Simulcast encoding: 3 spatial layers (180p, 360p, 720p). SFU selects layer per receiver based on bandwidth.
- SVC (Scalable Video Coding) with VP9: single encode, layered. SFU drops layers without re-encode. Lower CPU.
- Dynamic bitrate adaptation: monitor
RTCRemoteInboundRtpStreamStats round-trip time + packet loss. Reduce bitrate when RTT > 300ms or loss > 5%.
- Audio red (Redundant Audio Data): send redundant packets at 50% rate. Tolerates 20% packet loss without audio drop.
- Keyframe request burst: when a new peer joins, request keyframe from publisher. Throttle to 1 per 2 seconds.
Server-Side Performance
- SFU pipeline: receive RTP packet → decrypt (DTLS) → demux by SSRC → queue per consumer → encrypt → send. Minimize copies: use
Buffer pools.
- UDP buffer sizes:
net.core.rmem_default = 26214400, net.core.wmem_default = 26214400. Prevents packet drop under load.
- Thread pinning: media worker threads pinned to dedicated CPU cores. Signaling on separate cores. No context switching for media path.
- TURN bandwidth: one
coturn instance handles ~1Gbps per 4 vCPU / 8GB RAM. Horizontal scale with DNS round-robin.
Client-Side Optimization
- Hardware acceleration:
navigator.mediaDevices.getUserMedia with video: { width: 640, height: 480, frameRate: 30 } — don't encode 4K for video calls.
- Decode only visible streams in grid view: if user sees 4 tiles, receive only 4 streams. SFU stops forwarding others.
- WebCodecs API (Chrome 94+): lower-latency encode/decode compared to browser default. Manual frame control.
- Connection quality estimator: client measures throughput and classifies as
excellent / good / poor / degraded. UI adapts (disable HD, hide tiles).
Security Considerations (Expanded)
- Peer authentication: validate JWT in
connect() callback. Token includes roomId, userId, exp. Reject expired tokens.
- Media encryption: WebRTC mandates DTLS-SRTP (AES-128 or AES-256). No plaintext RTP. Verify
getStats() shows dtlsState: 'connected'.
- TURN authentication: time-limited HMAC credentials.
username = timestamp:userid, credential = HMAC(shared_secret, username). Rotate shared secret weekly.
- Signaling input validation: sanitize roomId to alphanumeric + hyphen only. Reject payloads exceeding 10KB. Rate limit messages per connection.
- Screen sharing: explicit
getDisplayMedia() prompt (browser-enforced). Never auto-accept. Server can request track.stop() on disconnect.
- Recording: server-side recording via SFU (mediasoup
rtp-parameters → FFmpeg pipe). Recorded files encrypted at rest. Access logged.
- CORS: signaling server origins restricted to known domains.
Access-Control-Allow-Origin set per environment, never wildcard.
- SFU consumer admission: consumer must present valid producer ID received via signaling. Prevent rogue peer subscribing to any stream.
- DoS prevention: per-peer inbound bitrate cap (configurable, e.g. 10Mbps). Drop packets exceeding limit. Notify via signaling to reduce quality.
- Logging: never log SDP payloads (may contain local IPs). Log message type, roomId, peerId, timestamps only.
Burn-Rate Alerting Configuration
alerts:
ice_failure_rate:
window: 5m
threshold: "> 5%"
action: page
turn_bandwidth:
window: 10m
threshold: "> 80%"
action: page
signaling_latency_p99:
window: 5m
threshold: "> 200ms"
action: alert
call_success_rate:
window: 30m
threshold: "< 95%"
action: page
packet_loss_avg:
window: 5m
threshold: "> 3%"
action: alert
References
1---2name: backend-web-real-time3description: Use when the user asks about WebRTC, real-time video/audio, media streaming, SFU/MCU, signaling server, TURN/STUN, live streaming, WebSocket for media, or real-time communication infrastructure. Do NOT use for: basic WebSocket patterns (websocket-patterns), or general real-time updates (data-streaming).4license: MIT5---67# Web Real-Time Communication89## Purpose10Build real-time communication systems: WebRTC signaling, media server architecture (SFU/MCU), TURN/STUN infrastructure, live streaming, and real-time data channels.1112## Agent Protocol1314### Trigger15User mentions WebRTC, real-time video/audio, media streaming, SFU, MCU, signaling server, TURN/STUN, live streaming, media server, coturn, or real-time communication infrastructure.1617### Input Context18- Use case (video call, live streaming, conferencing, data channel)19- Expected participant count (1-1, small group <10, large group >10)20- Network conditions (NAT/firewall, mobile, enterprise)21- Media types (audio, video, screen share, data channel)22- Deployment model (self-hosted, hybrid, cloud)23- Latency requirements (real-time, near-real-time, low-latency streaming)2425### Output Artifact26Architecture design with signaling protocol, media server topology, TURN/STUN config, client integration code, and deployment infrastructure.2728### Response Format29No preamble. No postamble. No explanations. No filler/hedging/transitions. Strip articles a/an/the where unambiguous. Compress output.3031### Completion Criteria32- [ ] Signaling server protocol selected (WebSocket, HTTP, SSE) and implemented33- [ ] Media server architecture chosen (SFU > MCU > P2P mesh) per scale requirements34- [ ] TURN/STUN infrastructure specified with credentials management35- [ ] ICE/STUN/TURN configuration working across target network topologies36- [ ] Media negotiation (SDP offer/answer) flow implemented37- [ ] Client SDK integration complete with reconnection logic38- [ ] Bandwidth estimation and adaptation strategy defined39- [ ] Recording/archiving strategy defined if required40- [ ] Fallback for restricted networks documented4142### Max Response Length434096 tokens4445## Architecture Decision Trees4647### Topology Selection4849| Criterion | Mesh (P2P) | SFU (Selective Forwarding) | MCU (Multipoint Control) |50|-----------|-----------|---------------------------|--------------------------|51| Participants | 2-4 | 4-100+ | 4-50 |52| Server bandwidth | None | O(n) — one uplink per peer | O(1) — single mixed stream |53| Client bandwidth | O(n^2) — send to all peers | O(1) — send once, receive n-1 | O(1) — single stream |54| Latency | Lowest | Low (no mixing) | Higher (mixing delay) |55| CPU cost | Client-side | Server relays | Server transcodes |56| Complexity | Simplest | Moderate | High |57| Fallback | STUN only | STUN + TURN | STUN + TURN |5859Decision: Always prefer SFU for multi-party. Mesh for 1-1 calls. MCU only when legacy client compatibility required.6061### Signaling Protocol Decision6263| Criterion | WebSocket | HTTP Long Polling | SSE |64|-----------|-----------|-------------------|-----|65| Latency | <50ms | 200-500ms | 100-300ms |66| Bidirectional | Yes | Yes | Server→Client only |67| NAT Traversal | Native ws:// wss:// | HTTP (always works) | HTTP (always works) |68| Browser Support | All modern | All | All (except IE) |69| Connection overhead | Upgrade handshake | New request per poll | Persistent connection |70| Reconnection | Built-in (some libs) | Stateless | Built-in (EventSource) |7172Decision: WebSocket for production signaling. SSE for one-way broadcast. HTTP polling as last resort for restrictive networks.7374### Codec Selection7576| Codec | Bitrate | Quality | Licensing | Browser Support |77|-------|---------|---------|-----------|-----------------|78| VP8 | 200-1500 kbps | Good | Royalty-free | Chrome, Firefox, Safari 14.1+ |79| VP9 | 150-800 kbps | Better | Royalty-free | Chrome, Firefox |80| H.264 | 200-1500 kbps | Excellent | Patent-encumbered | Chrome, Firefox, Safari, Edge |81| AV1 | 100-500 kbps | Best | Royalty-free | Chrome (limited) |8283Decision: H.264 for widest compatibility. VP9/AV1 for bandwidth-constrained environments.8485## Workflow8687### WebRTC Architecture88```89Peer A → Signaling Server ← Peer B90 | |91 ↓ ↓92STUN/TURN Server ← → ICE Negotiation ← → STUN/TURN Server93 | |94 ↓ ↓95 └─────── Media via SRTP/SCTP ────────┘96```9798### Signaling Server Patterns99| Protocol | When to Use | Example |100|----------|-------------|---------|101| WebSocket | Low latency, bidirectional | Socket.io, ws |102| HTTP Long Polling | Simple, no WebSocket support | REST endpoints |103| Server-Sent Events | One-way server→client | Event notifications |104105### Media Server Architecture106| Architecture | Description | Pros | Cons |107|-------------|-------------|------|------|108| Mesh (P2P) | Every peer connects to every other | No server cost | Bandwidth scales O(n^2) |109| MCU | Server mixes all streams into one | Low client bandwidth | Single point of failure |110| SFU | Server relays streams, selective forwarding | Scalable, flexible | Higher server bandwidth |111112### SFU (Selective Forwarding Unit) - Recommended113- Each peer sends one stream to SFU114- SFU forwards to all other peers115- Peers can choose which streams to receive116- Scales to hundreds of participants117118### TURN/STUN Server Setup119| Component | Purpose | Service |120|-----------|---------|---------|121| STUN | Discover public IP/port for NAT traversal | coturn (self-hosted), Google STUN |122| TURN | Relay media when P2P fails (NAT/firewall) | coturn (self-hosted), Twilio Network Traversal |123124## Implementation Patterns125126### Pattern: Signaling with WebSocket (Node.js)127128```typescript129// server/signaling.ts130import { WebSocketServer, WebSocket } from 'ws';131132interface SignalingMessage {133 type: 'offer' | 'answer' | 'ice-candidate' | 'join' | 'leave' | 'room-info';134 roomId: string;135 senderId: string;136 payload: unknown;137}138139const wss = new WebSocketServer({ port: 8080 });140const rooms = new Map<string, Map<string, WebSocket>>();141142wss.on('connection', (ws) => {143 let userId: string;144 let currentRoom: string;145146 ws.on('message', (data) => {147 const msg: SignalingMessage = JSON.parse(data.toString());148149 switch (msg.type) {150 case 'join': {151 userId = msg.senderId;152 currentRoom = msg.roomId;153 if (!rooms.has(msg.roomId)) rooms.set(msg.roomId, new Map());154 rooms.get(msg.roomId)!.set(userId, ws);155 broadcastToRoom(msg.roomId, { type: 'room-info', roomId: msg.roomId, senderId: 'system', payload: { peers: Array.from(rooms.get(msg.roomId)!.keys()) } }, userId);156 break;157 }158 case 'offer':159 case 'answer':160 case 'ice-candidate': {161 const targetPeer = rooms.get(msg.roomId)?.get(msg.payload.targetId);162 if (targetPeer?.readyState === WebSocket.OPEN) targetPeer.send(JSON.stringify(msg));163 break;164 }165 case 'leave': {166 rooms.get(msg.roomId)?.delete(userId);167 broadcastToRoom(msg.roomId, { type: 'leave', roomId: msg.roomId, senderId: userId, payload: {} }, userId);168 }169 }170 });171172 ws.on('close', () => {173 rooms.get(currentRoom)?.delete(userId);174 if (rooms.get(currentRoom)?.size === 0) rooms.delete(currentRoom);175 broadcastToRoom(currentRoom, { type: 'leave', roomId: currentRoom, senderId: userId, payload: {} }, userId);176 });177});178179function broadcastToRoom(roomId: string, msg: SignalingMessage, excludeId?: string) {180 const room = rooms.get(roomId);181 if (!room) return;182 for (const [id, ws] of room) {183 if (id !== excludeId && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));184 }185}186```187188### Pattern: Client-Side WebRTC Peer Connection189190```typescript191// client/webrtc.ts192export class WebRTCClient {193 private pc: RTCPeerConnection | null = null;194 private signaling: WebSocket;195 private localStream: MediaStream | null = null;196 private remoteStream = new MediaStream();197198 constructor(serverUrl: string, private userId: string, private roomId: string) {199 this.signaling = new WebSocket(serverUrl);200 this.setupSignaling();201 }202203 private setupSignaling() {204 this.signaling.onopen = () => {205 this.signaling.send(JSON.stringify({ type: 'join', roomId: this.roomId, senderId: this.userId, payload: {} }));206 };207 this.signaling.onmessage = async (event) => {208 const msg = JSON.parse(event.data);209 switch (msg.type) {210 case 'offer': await this.handleOffer(msg); break;211 case 'answer': await this.pc!.setRemoteDescription(new RTCSessionDescription(msg.payload)); break;212 case 'ice-candidate': await this.pc!.addIceCandidate(new RTCIceCandidate(msg.payload)); break;213 }214 };215 }216217 async startCall(constraints: MediaStreamConstraints = { audio: true, video: true }) {218 this.localStream = await navigator.mediaDevices.getUserMedia(constraints);219 this.pc = new RTCPeerConnection({220 iceServers: [221 { urls: 'stun:stun.l.google.com:19302' },222 { urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' },223 ],224 });225 this.localStream.getTracks().forEach(t => this.pc!.addTrack(t, this.localStream!));226 this.pc.ontrack = (event) => { event.streams[0].getTracks().forEach(t => this.remoteStream.addTrack(t)); };227 this.pc.onicecandidate = (event) => {228 if (event.candidate) this.signaling.send(JSON.stringify({ type: 'ice-candidate', roomId: this.roomId, senderId: this.userId, payload: { targetId: 'peer-id', candidate: event.candidate } }));229 };230 const offer = await this.pc.createOffer();231 await this.pc.setLocalDescription(offer);232 this.signaling.send(JSON.stringify({ type: 'offer', roomId: this.roomId, senderId: this.userId, payload: { targetId: 'peer-id', sdp: offer } }));233 }234235 private async handleOffer(msg: any) {236 this.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });237 this.pc.ontrack = (event) => { event.streams[0].getTracks().forEach(t => this.remoteStream.addTrack(t)); };238 this.pc.onicecandidate = (event) => {239 if (event.candidate) this.signaling.send(JSON.stringify({ type: 'ice-candidate', roomId: this.roomId, senderId: this.userId, payload: { targetId: msg.senderId, candidate: event.candidate } }));240 };241 await this.pc.setRemoteDescription(new RTCSessionDescription(msg.payload.sdp));242 const answer = await this.pc.createAnswer();243 await this.pc.setLocalDescription(answer);244 this.signaling.send(JSON.stringify({ type: 'answer', roomId: this.roomId, senderId: this.userId, payload: { targetId: msg.senderId, sdp: answer } }));245 }246}247```248249### Pattern: SFU Selective Forwarding (mediasoup)250251```typescript252// sfu-server.ts253import * as mediasoup from 'mediasoup';254import { WebSocketServer } from 'ws';255256async function createSfu() {257 const worker = await mediasoup.createWorker();258 const router = await worker.createRouter({259 mediaCodecs: [260 { kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2 },261 { kind: 'video', mimeType: 'video/VP8', clockRate: 90000 },262 { kind: 'video', mimeType: 'video/H264', clockRate: 90000, parameters: { 'level-asymmetry-allowed': 1, 'packetization-mode': 1, 'profile-level-id': '42e01f' } },263 ],264 });265266 const wss = new WebSocketServer({ port: 8081 });267 const peers = new Map<string, { transport: mediasoup.types.WebRtcTransport; producer: mediasoup.types.Producer | null }>();268269 wss.on('connection', (ws) => {270 ws.on('message', async (data) => {271 const msg = JSON.parse(data.toString());272 if (msg.type === 'create-transport') {273 const transport = await router.createWebRtcTransport({274 listenIps: [{ ip: '0.0.0.0', announcedIp: process.env.PUBLIC_IP }],275 enableUdp: true, enableTcp: true, preferUdp: true,276 initialAvailableOutgoingBitrate: 1000000,277 });278 peers.set(msg.peerId, { transport, producer: null });279 ws.send(JSON.stringify({ type: 'transport-created', id: transport.id, iceParameters: transport.iceParameters, iceCandidates: transport.iceCandidates, dtlsParameters: transport.dtlsParameters }));280 }281 if (msg.type === 'connect-transport') {282 await peers.get(msg.peerId)!.transport.connect({ dtlsParameters: msg.dtlsParameters });283 ws.send(JSON.stringify({ type: 'transport-connected' }));284 }285 if (msg.type === 'produce') {286 const peer = peers.get(msg.peerId)!;287 const producer = await peer.transport.produce({ kind: msg.kind, rtpParameters: msg.rtpParameters });288 peer.producer = producer;289 // Forward to all other peers290 for (const [id, p] of peers) {291 if (id !== msg.peerId && p.transport) {292 const consumer = await p.transport.consume({ producerId: producer.id, rtpCapabilities: router.rtpCapabilities });293 ws.send(JSON.stringify({ type: 'new-consumer', peerId: id, producerId: producer.id, id: consumer.id, kind: consumer.kind, rtpParameters: consumer.rtpParameters }));294 }295 }296 ws.send(JSON.stringify({ type: 'produced', id: producer.id }));297 }298 });299 });300}301```302303### Pattern: TURN Server with coturn304305```bash306# docker-compose.yml307version: '3.8'308services:309 coturn:310 image: coturn/coturn:latest311 network_mode: host312 command: >313 -n --log-file=stdout314 --min-port=49152 --max-port=65535315 --fingerprint --lt-cred-mech316 --realm=example.com317 --user=appuser:securepassword318 --external-ip=YOUR_PUBLIC_IP319 ports:320 - "3478:3478/udp"321 - "3478:3478/tcp"322 - "5349:5349/tcp"323 - "49152-65535:49152-65535/udp"324```325326```typescript327// TURN credential generation (time-limited)328import crypto from 'crypto';329330function generateTurnCredentials(sharedSecret: string, username: string, ttl = 86400): { username: string; credential: string } {331 const timestamp = Math.floor(Date.now() / 1000) + ttl;332 const turnUser = `${timestamp}:${username}`;333 const hmac = crypto.createHmac('sha1', sharedSecret).update(turnUser).digest('base64');334 return { username: turnUser, credential: hmac };335}336```337338## Production Considerations339340### Scalability341- SFU horizontal scaling: use Redis pub/sub to share peer state across signaling server instances342- Media servers are CPU-bound — monitor packet loss and jitter; scale by concurrent rooms343- TURN bandwidth: budget 2-5 Mbps per active media stream; TURN egress costs dominate344- WebSocket signaling: one connection per peer; plan for 10K+ concurrent connections per node345346### Deployment347- Separate signaling and media planes — signaling can scale independently from media348- Place TURN servers near users (edge locations) to minimize relay latency349- Use Kubernetes headless services for WebSocket signaling with session affinity350- Monitor: ICE failures, TURN bandwidth, packet loss, jitter, round-trip time351352### Monitoring353- Key metrics: ICE connection time, call success rate, media bitrate, packet loss, jitter buffer delay354- Alerts: elevated ICE failure rate >5%, TURN bandwidth >80% capacity, signaling latency >200ms355- Logging: structured JSON logs for all signaling messages (type, roomId, peerId, duration)356357## Anti-Patterns358359| Anti-Pattern | Why | Fix |360|-------------|-----|-----|361| Relying on P2P mesh for >4 participants | Bandwidth O(n^2) kills clients | Use SFU for any multi-party |362| Hardcoded STUN/TURN URLs | Rotate credentials; no auth on STUN | Use credential-generating TURN with time-limited tokens |363| No reconnection logic | WebRTC fails on network change; no recovery | Implement ICE restart + signaling reconnection |364| Sending raw RTP without congestion control | Network collapse under load | Use WebRTC built-in GCC or SCream congestion control |365| Single signaling server | SPOF for entire system | Load balance signaling; use Redis for room state |366| No simulcast/SVC encoding | All receive same quality regardless of bandwidth | VP9 SVC or VP8 simulcast with 3 spatial layers |367| Blocking TURN ports | Users behind symmetric NAT can't connect | Document required ports: 3478 (TURN), 49152-65535 (media) |368369## Security Considerations370371- Always use WSS (WebSocket Secure) and TURN over TLS (5349) — never plain WS/STUN372- TURN credentials: time-limited HMAC, never static passwords; rotate shared secret weekly373- Media encryption: SRTP with DTLS-SRTP key exchange (mandatory in WebRTC)374- Signaling authentication: verify JWT/token before allowing room join; reject unauthenticated offers375- Room access control: token must include roomId; validate on every signaling message376- Rate limit signaling messages per peer (e.g., 50/s) to prevent DoS via offer flooding377- Don't expose internal IPs via ICE candidates — use mDNS ICE candidate in browsers (private IPs hidden)378- Screen sharing: implement user consent dialog; never auto-share379380## Testing Strategies381382```typescript383import { describe, it, expect, beforeAll, afterAll } from 'vitest';384import { WebSocket } from 'ws';385386describe('WebRTC Signaling Server', () => {387 let ws: WebSocket;388389 beforeAll(() => { ws = new WebSocket('ws://localhost:8080'); });390 afterAll(() => ws.close());391392 it('handles room join and broadcasts peer list', (done) => {393 ws.on('message', (data) => {394 const msg = JSON.parse(data.toString());395 if (msg.type === 'room-info') {396 expect(msg.payload.peers).toContain('peer-a');397 done();398 }399 });400 ws.send(JSON.stringify({ type: 'join', roomId: 'test-room', senderId: 'peer-a', payload: {} }));401 });402403 it('relays offers between peers', (done) => {404 const ws2 = new WebSocket('ws://localhost:8080');405 ws2.on('message', (data) => {406 const msg = JSON.parse(data.toString());407 if (msg.type === 'offer') {408 expect(msg.payload.sdp.type).toBe('offer');409 ws2.close();410 done();411 }412 });413 ws2.on('open', () => {414 ws2.send(JSON.stringify({ type: 'join', roomId: 'test-room', senderId: 'peer-b', payload: {} }));415 ws.send(JSON.stringify({ type: 'offer', roomId: 'test-room', senderId: 'peer-a', payload: { targetId: 'peer-b', sdp: { type: 'offer', sdp: 'v=0...' } } }));416 });417 });418});419```420421- Test ICE connectivity across simulated network conditions (packet loss, latency, NAT)422- Use test WebRTC peers (e.g., puppeteer with Chrome) for E2E media tests423- Load test signaling server with 1000+ concurrent connections424- Validate TURN allocation and deallocation under load425- Run connectivity matrix: test all combinations of client networks (public, NAT, symmetric NAT)426427## Rules428- SFU for multi-party calls, P2P mesh for 1-1 only. Never use MCU without transcoding requirement.429- TURN credentials must be time-limited (max 24h TTL). Never use static passwords.430- Signaling must use WSS (not WS) in production. Certificates on first byte.431- Always implement ICE restart on connection loss. Reconnection is mandatory, not optional.432- Media codec negotiation: prefer H.264 for compatibility, VP9/AV1 for quality-per-bitrate.433- Bandwidth estimation must be enabled (REMBB or TWCC). Never send without congestion control.434- Monitor: ICE failures, call duration, packet loss, jitter, RTT per peer.435- Recording: don't record from client-side — record from SFU (server-side) for sync quality.436- Simulcast for multi-party: encode 3 layers (low/medium/high) so SFU can adapt per receiver.437438## Metrics & SLAs439| Metric | Target | Critical |440|--------|--------|----------|441| ICE connection time | <3s | >10s |442| Call success rate | >98% | <95% |443| End-to-end latency (audio) | <150ms | >400ms |444| End-to-end latency (video) | <300ms | >500ms |445| Packet loss | <1% | >5% |446| Jitter | <30ms | >80ms |447| TURN relay bandwidth | <70% capacity | >90% capacity |448| Signaling latency | <100ms | >500ms |449450## Handoff451- `backend/universal/websocket-patterns` — WebSocket fundamentals and optimizations452- `backend/universal/data-streaming` — Real-time data streaming patterns453- `backend/universal/load-testing` — Load testing signaling and media infrastructure454- `security/network` — TURN/STUN firewall and network security455456## Edge Cases457- **Symmetric NAT + firewall**: TURN relay required; test with TURN-only clients before launch458- **Mobile network handoff**: ICE restart on IP change; implement with `connectionstatechange` listener459- **Browser tab backgrounding**: Chrome throttles timers; use `AudioContext` timer for audio sync460- **Multi-radio (WiFi + cellular)**: ICE may prefer wrong interface; set `iceTransportPolicy: 'relay'` for critical sessions461- **Simulcast with bandwidth drop**: SFU should signal layer switch via `RTCRtpSender.setParameters` dynamically462463## Performance Optimization464465### Bandwidth & Bitrate Management466- Simulcast encoding: 3 spatial layers (180p, 360p, 720p). SFU selects layer per receiver based on bandwidth.467- SVC (Scalable Video Coding) with VP9: single encode, layered. SFU drops layers without re-encode. Lower CPU.468- Dynamic bitrate adaptation: monitor `RTCRemoteInboundRtpStreamStats` round-trip time + packet loss. Reduce bitrate when RTT > 300ms or loss > 5%.469- Audio red (Redundant Audio Data): send redundant packets at 50% rate. Tolerates 20% packet loss without audio drop.470- Keyframe request burst: when a new peer joins, request keyframe from publisher. Throttle to 1 per 2 seconds.471472### Server-Side Performance473- SFU pipeline: receive RTP packet → decrypt (DTLS) → demux by SSRC → queue per consumer → encrypt → send. Minimize copies: use `Buffer` pools.474- UDP buffer sizes: `net.core.rmem_default = 26214400`, `net.core.wmem_default = 26214400`. Prevents packet drop under load.475- Thread pinning: media worker threads pinned to dedicated CPU cores. Signaling on separate cores. No context switching for media path.476- TURN bandwidth: one `coturn` instance handles ~1Gbps per 4 vCPU / 8GB RAM. Horizontal scale with DNS round-robin.477478### Client-Side Optimization479- Hardware acceleration: `navigator.mediaDevices.getUserMedia` with `video: { width: 640, height: 480, frameRate: 30 }` — don't encode 4K for video calls.480- Decode only visible streams in grid view: if user sees 4 tiles, receive only 4 streams. SFU stops forwarding others.481- WebCodecs API (Chrome 94+): lower-latency encode/decode compared to browser default. Manual frame control.482- Connection quality estimator: client measures throughput and classifies as `excellent / good / poor / degraded`. UI adapts (disable HD, hide tiles).483484## Security Considerations (Expanded)485486- **Peer authentication**: validate JWT in `connect()` callback. Token includes `roomId`, `userId`, `exp`. Reject expired tokens.487- **Media encryption**: WebRTC mandates DTLS-SRTP (AES-128 or AES-256). No plaintext RTP. Verify `getStats()` shows `dtlsState: 'connected'`.488- **TURN authentication**: time-limited HMAC credentials. `username = timestamp:userid`, `credential = HMAC(shared_secret, username)`. Rotate shared secret weekly.489- **Signaling input validation**: sanitize roomId to alphanumeric + hyphen only. Reject payloads exceeding 10KB. Rate limit messages per connection.490- **Screen sharing**: explicit `getDisplayMedia()` prompt (browser-enforced). Never auto-accept. Server can request `track.stop()` on disconnect.491- **Recording**: server-side recording via SFU (mediasoup `rtp-parameters` → FFmpeg pipe). Recorded files encrypted at rest. Access logged.492- **CORS**: signaling server origins restricted to known domains. `Access-Control-Allow-Origin` set per environment, never wildcard.493- **SFU consumer admission**: consumer must present valid producer ID received via signaling. Prevent rogue peer subscribing to any stream.494- **DoS prevention**: per-peer inbound bitrate cap (configurable, e.g. 10Mbps). Drop packets exceeding limit. Notify via signaling to reduce quality.495- **Logging**: never log SDP payloads (may contain local IPs). Log message type, roomId, peerId, timestamps only.496497### Burn-Rate Alerting Configuration498```yaml499alerts:500 ice_failure_rate:501 window: 5m502 threshold: "> 5%"503 action: page504 turn_bandwidth:505 window: 10m506 threshold: "> 80%"507 action: page508 signaling_latency_p99:509 window: 5m510 threshold: "> 200ms"511 action: alert512 call_success_rate:513 window: 30m514 threshold: "< 95%"515 action: page516 packet_loss_avg:517 window: 5m518 threshold: "> 3%"519 action: alert520```521522## References