Implementing WebRTC for Video Calls on Website
WebRTC not just "add video calls". Complete protocol suite: ICE, STUN, TURN, SDP-negotiation, DTLS-SRTP and media pipeline via getUserMedia. Without understanding each component, get calls working only local network or failing behind NAT.
WebRTC Stack Components
Browser provides RTCPeerConnection — central object managing everything: ICE candidates, media streams, encryption. Needs signaling server — WebRTC doesn't define how two clients exchange SDP offers. Separate task.
Typical production stack:
| Component | Options |
|---|---|
| Signaling server | Socket.IO, WebSocket (Go/Node), Phoenix Channels |
| ICE/STUN | coturn, Twilio STUN, Google STUN |
| TURN server | coturn on VPS, Twilio TURN, Xirsys |
| Media server (SFU) | mediasoup, Janus, LiveKit, Jitsi Videobridge |
| Client library | native RTCPeerConnection or simple-peer, mediasoup-client |
For P2P calls (up to 4 participants) SFU unnecessary. At 5+ participants mesh topology creates n(n-1)/2 connections per person — unacceptable. Need SFU.
P2P Call Architecture
Alice Signal Server Bob
|------ offer SDP -------->| |
| |------ offer SDP ->|
|<----- answer SDP --------| |
| |<---- answer SDP --|
|<========= ICE candidates exchange =========>|
|<============== DTLS handshake ==============>|
|<======= encrypted RTP/RTCP media stream ====>|
Each browser collects ICE candidates: host (local IP), srflx (public IP via STUN), relay (via TURN). TURN relay needed ~15–20% connections — corporate firewalls, symmetric NAT.
ICE and TURN: Why Nothing Works Without
STUN answers "what's my external IP?". TURN proxies media when direct connection impossible. coturn — standard self-hosted choice:
# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
realm=yourdomain.com
server-name=yourdomain.com
lt-cred-mech
use-auth-secret
static-auth-secret=YOUR_SECRET
total-quota=100
bps-capacity=0
stale-nonce=600
cert=/etc/letsencrypt/live/yourdomain.com/fullchain.pem
pkey=/etc/letsencrypt/live/yourdomain.com/privkey.pem
TURN via TLS on 443 port bypasses most corporate restrictions.
Signaling Server on Node.js + Socket.IO
io.on('connection', (socket) => {
socket.on('join-room', (roomId, userId) => {
socket.join(roomId);
socket.to(roomId).emit('user-connected', userId);
socket.on('offer', (offer, targetId) => {
io.to(targetId).emit('offer', offer, socket.id);
});
socket.on('answer', (answer, targetId) => {
io.to(targetId).emit('answer', answer, socket.id);
});
socket.on('ice-candidate', (candidate, targetId) => {
io.to(targetId).emit('ice-candidate', candidate, socket.id);
});
socket.on('disconnect', () => {
socket.to(roomId).emit('user-disconnected', userId);
});
});
});
RTCPeerConnection on Client
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.yourdomain.com:3478' },
{
urls: 'turn:turn.yourdomain.com:3478',
username: generateTurnUsername(ttl),
credential: generateTurnCredential(username, secret),
},
],
iceTransportPolicy: 'all', // 'relay' to force TURN
});
// Add media
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 48000 },
});
stream.getTracks().forEach(track => pc.addTrack(track, stream));
// Negotiation
pc.onicecandidate = ({ candidate }) => {
if (candidate) socket.emit('ice-candidate', candidate, targetId);
};
pc.onnegotiationneeded = async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('offer', offer, targetId);
};
Codecs and Quality
Browsers negotiate codecs via SDP. Video: VP8, VP9 or H.264; audio: Opus. Force preferred codec via SDP manipulation:
function preferCodec(sdp, codecName) {
const lines = sdp.split('\n');
// Find PT and move to start of m= section
// ...
return lines.join('\n');
}
const offer = await pc.createOffer();
offer.sdp = preferCodec(offer.sdp, 'VP9'); // VP9 — best quality per bitrate
await pc.setLocalDescription(offer);
Adaptive bitrate via RTCRtpSender.setParameters:
const sender = pc.getSenders().find(s => s.track.kind === 'video');
const params = sender.getParameters();
params.encodings[0].maxBitrate = 800000; // 800 kbps
await sender.setParameters(params);
Simulcast for Scalable Conferences
With SFU (mediasoup, LiveKit) use Simulcast — client sends multiple streams different resolutions:
pc.addTransceiver(videoTrack, {
direction: 'sendonly',
sendEncodings: [
{ rid: 'low', maxBitrate: 150000, scaleResolutionDownBy: 4 },
{ rid: 'mid', maxBitrate: 500000, scaleResolutionDownBy: 2 },
{ rid: 'high', maxBitrate: 1500000 },
],
});
SFU picks needed layer per receiver by bandwidth.
Recording Calls
Server-side recording via SFU preferable. Client-side:
const recorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9,opus',
videoBitsPerSecond: 2500000,
});
const chunks = [];
recorder.ondataavailable = e => chunks.push(e.data);
recorder.onstop = () => {
const blob = new Blob(chunks, { type: 'video/webm' });
uploadToServer(blob);
};
recorder.start(1000);
Diagnostics and Monitoring
getStats() — main debugging tool:
setInterval(async () => {
const stats = await pc.getStats();
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.kind === 'video') {
console.log({
packetsLost: report.packetsLost,
jitter: report.jitter,
framesDecoded: report.framesDecoded,
framesPerSecond: report.framesPerSecond,
});
}
});
}, 2000);
For production monitoring — Datadog WebRTC or open webrtc-internals (chrome://webrtc-internals) during debug.
Timeline and Effort
- P2P video call with signaling server — 3–5 days (two participants, basic controls)
- Group calls via SFU (mediasoup/LiveKit) — 2–3 weeks (server setup, scaling, rooms)
- Recording + postprocessing — plus 1 week
- Full conference platform (rooms, chat, screenshare, recording) — 6–10 weeks
Compatibility
WebRTC supported all modern browsers. Safari supports from version 11, but has limitations: no Insertable Streams support in older versions, renegotiation issues. iOS requires native app or PWA — browser WebRTC on iOS Safari works from version 14.5.







