WebRTC (Web Real-Time Communication) is the only browser API that enables real-time peer-to-peer video, audio, and data transfer without plugins or third-party software. It powers Google Meet, Discord, and countless other applications — all running directly in the browser with built-in encryption and NAT traversal.
Unlike traditional communication protocols that require server relays for media, WebRTC establishes direct connections between peers using ICE, STUN, and TURN, minimizing latency and bandwidth costs.
Prerequisites
- Basic knowledge of JavaScript and asynchronous programming (Promises, async/await)
- Node.js 18+ installed (for the signaling server example)
- A modern browser (Chrome, Firefox, Safari, or Edge)
- Familiarity with WebSockets is helpful but not required
What is WebRTC?
// Basic WebRTC - peer-to-peer video
const pc = new RTCPeerConnection(iceServers);
pc.ontrack = (event) => {
document.getElementById('video').srcObject = event.streams[0];
};
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => stream.getTracks().forEach(track => pc.addTrack(track, stream)));
sequenceDiagram
participant A as Browser A
participant S as Signaling Server
participant B as Browser B
participant STUN as STUN Server
A->>S: 1. Join room (WebSocket)
S->>B: 2. Notify: user joined
A->>A: 3. Create RTCPeerConnection
A->>STUN: 4. STUN request (get public IP)
STUN->>A: 5. Return public IP:port
A->>S: 6. Send SDP offer
S->>B: 7. Forward offer
B->>B: 8. Create RTCPeerConnection
B->>S: 9. Send SDP answer
S->>A: 10. Forward answer
A->>S: 11. Send ICE candidates
S->>B: 12. Forward ICE candidates
B->>S: 13. Send ICE candidates
S->>A: 14. Forward ICE candidates
Note over A,B: 15. Direct P2P media & data
A->>B: encrypted media stream (SRTP)
B->>A: encrypted media stream (SRTP)
Key Features
- Video/Audio - Real-time media streaming
- P2P - Direct peer-to-peer connections
- Data Channels - Arbitrary data transfer
- NAT Traversal - ICE, STUN, TURN
- Encryption - DTLS/SRTP built-in
- Standards-based - W3C/IETF standard
Core Components
RTCPeerConnection
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.example.com:3478',
username: 'user',
credential: 'pass'
}
]
};
const pc = new RTCPeerConnection(config);
// Handle remote tracks
pc.ontrack = (event) => {
console.log('Received remote track:', event.track.kind);
const stream = event.streams[0];
// Add stream to video element
};
// Handle ICE candidates
pc.onicecandidate = (event) => {
if (event.candidate) {
// Send candidate to peer via signaling
sendSignalingMessage({ type: 'candidate', candidate: event.candidate });
}
};
// Connection state
pc.onconnectionstatechange = () => {
console.log('Connection state:', pc.connectionState);
};
// ICE connection state
pc.oniceconnectionstatechange = () => {
console.log('ICE state:', pc.iceConnectionState);
};
Media Streams
// Get user media
async function getUserMedia() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 }
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
return stream;
} catch (error) {
console.error('Error accessing media devices:', error);
}
}
// Display local video
const localVideo = document.getElementById('localVideo');
localVideo.srcObject = localStream;
localVideo.muted = true; // Mute local playback
RTCSessionDescription
// Create offer
async function createOffer(pc) {
const offer = await pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await pc.setLocalDescription(offer);
return offer;
}
// Create answer
async function createAnswer(pc) {
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
return answer;
}
// Set remote description
async function setRemoteDescription(pc, description) {
await pc.setRemoteDescription(new RTCSessionDescription(description));
}
WebRTC relies on a signaling channel — typically WebSocket — to exchange session descriptions and ICE candidates before a direct peer-to-peer connection can be established. The signaling server itself does not carry media data; it only facilitates the initial handshake.
Signaling
Server Setup (Node.js)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
const rooms = new Map();
wss.on('connection', (ws) => {
let roomId = null;
let clientId = null;
ws.on('message', (message) => {
const data = JSON.parse(message);
switch (data.type) {
case 'join':
roomId = data.roomId;
clientId = Date.now().toString();
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId).add({ ws, clientId });
// Notify others in room
broadcastToRoom(roomId, {
type: 'user-joined',
clientId
}, clientId);
// Send existing users to new client
const existingClients = Array.from(rooms.get(roomId))
.filter(c => c.clientId !== clientId)
.map(c => c.clientId);
ws.send(JSON.stringify({
type: 'room-users',
users: existingClients
}));
break;
case 'offer':
case 'answer':
case 'candidate':
// Forward to specific client
sendToClient(roomId, data.targetClientId, data);
break;
case 'leave':
removeFromRoom(roomId, clientId);
break;
}
});
ws.on('close', () => {
if (roomId && clientId) {
removeFromRoom(roomId, clientId);
}
});
});
function broadcastToRoom(roomId, message, excludeClientId) {
if (!rooms.has(roomId)) return;
const data = JSON.stringify(message);
rooms.get(roomId).forEach(client => {
if (client.clientId !== excludeClientId && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(data);
}
});
}
function sendToClient(roomId, targetClientId, message) {
if (!rooms.has(roomId)) return;
const data = JSON.stringify(message);
rooms.get(roomId).forEach(client => {
if (client.clientId === targetClientId && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(data);
}
});
}
function removeFromRoom(roomId, clientId) {
if (!rooms.has(roomId)) return;
rooms.get(roomId).forEach(client => {
if (client.clientId === clientId) {
client.ws.send(JSON.stringify({ type: 'user-left', clientId }));
rooms.get(roomId).delete(client);
}
});
}
Client Implementation
class SignalingClient {
constructor(url, roomId) {
this.ws = new WebSocket(url);
this.roomId = roomId;
this.pc = null;
this.clientId = null;
this.setupWebSocket();
}
setupWebSocket() {
this.ws.onopen = () => {
this.send({ type: 'join', roomId: this.roomId });
};
this.ws.onmessage = async (event) => {
const data = JSON.parse(event.data);
await this.handleSignalingMessage(data);
};
}
async handleSignalingMessage(data) {
switch (data.type) {
case 'room-users':
// Handle existing users
data.users.forEach(userId => this.createPeerConnection(userId, true));
break;
case 'user-joined':
this.createPeerConnection(data.clientId, false);
break;
case 'offer':
await this.handleOffer(data);
break;
case 'answer':
await this.handleAnswer(data);
break;
case 'candidate':
await this.handleCandidate(data);
break;
case 'user-left':
this.removePeerConnection(data.clientId);
break;
}
}
async createPeerConnection(peerId, isInitiator) {
this.pc = new RTCPeerConnection(iceServers);
this.pc.ontrack = (event) => {
// Display remote stream
const remoteVideo = document.getElementById('remoteVideo');
remoteVideo.srcObject = event.streams[0];
};
// Add local tracks
const localStream = await getUserMedia();
localStream.getTracks().forEach(track => {
this.pc.addTrack(track, localStream);
});
if (isInitiator) {
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.send({ type: 'offer', offer, targetClientId: peerId });
}
}
async handleOffer(data) {
await this.pc.setRemoteDescription(data.offer);
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.send({ type: 'answer', answer, targetClientId: data.clientId });
}
async handleAnswer(data) {
await this.pc.setRemoteDescription(data.answer);
}
async handleCandidate(data) {
await this.pc.addIceCandidate(new RTCIceCandidate(data.candidate));
}
send(message) {
this.ws.send(JSON.stringify(message));
}
}
Data Channels
Sending Data
const pc = new RTCPeerConnection(config);
// Create data channel
const dataChannel = pc.createDataChannel('chat', {
ordered: true, // Guarantee order
maxRetransmits: 30
});
dataChannel.onopen = () => {
console.log('Data channel opened');
};
dataChannel.onmessage = (event) => {
console.log('Received:', event.data);
};
// Send message
dataChannel.send(JSON.stringify({
type: 'message',
text: 'Hello!',
timestamp: Date.now()
}));
Receiving Data
pc.ondatachannel = (event) => {
const receiveChannel = event.channel;
receiveChannel.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};
receiveChannel.onopen = () => {
console.log('Receive channel opened');
};
};
Advanced Features
Screen Sharing
async function shareScreen() {
try {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always'
},
audio: false
});
// Replace video track
const videoTrack = screenStream.getVideoTracks()[0];
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
sender.replaceTrack(videoTrack);
// Handle stop sharing
videoTrack.onended = () => {
// Switch back to camera
navigator.mediaDevices.getUserMedia({ video: true })
.then(stream => {
const cameraTrack = stream.getVideoTracks()[0];
sender.replaceTrack(cameraTrack);
});
};
} catch (error) {
console.error('Error sharing screen:', error);
}
}
Connection Statistics
async function getStats(pc) {
const stats = await pc.getStats();
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.kind === 'video') {
console.log('Video bytes received:', report.bytesReceived);
console.log('Packets lost:', report.packetsLost);
console.log('Jitter:', report.jitter);
}
if (report.type === 'outbound-rtp' && report.kind === 'video') {
console.log('Video bytes sent:', report.bytesSent);
console.log('Round trip time:', report.roundTripTime);
}
});
}
// Monitor every 2 seconds
setInterval(() => getStats(pc), 2000);
Audio/Video Controls
// Mute/unmute
function toggleAudio(enabled) {
const audioTrack = localStream.getAudioTracks()[0];
audioTrack.enabled = enabled;
}
function toggleVideo(enabled) {
const videoTrack = localStream.getVideoTracks()[0];
videoTrack.enabled = enabled;
}
// Adjust bitrate
async function setVideoBitrate(pc, bitrate) {
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
const params = sender.getParameters();
if (!params.encodings) {
params.encodings = [{}];
}
params.encodings[0].maxBitrate = bitrate;
await sender.setParameters(params);
}
Production Considerations
STUN/TURN Server Configuration
For production deployments, always use authenticated TURN servers as a fallback:
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.example.com:3478',
username: process.env.TURN_USERNAME,
credential: process.env.TURN_CREDENTIAL
}
],
iceCandidatePoolSize: 10 // Pre-fetch candidates for faster connections
};
Connection Quality Monitoring
Track key metrics to detect degradation early:
| Metric | Good Range | Warning | Critical |
|---|---|---|---|
| Round-trip time | <150ms | 150-300ms | >300ms |
| Packet loss | <1% | 1-5% | >5% |
| Jitter | <30ms | 30-50ms | >50ms |
| Video bitrate | >1Mbps | 500K-1Mbps | <500Kbps |
Scalability
WebRTC is peer-to-peer, so bandwidth scales with participants:
- 1:1 calls: Direct P2P — no server bandwidth needed for media
- Small groups (3-6): Full mesh — each peer sends N-1 streams
- Large groups (7+): Use a Selective Forwarding Unit (SFU) or Multipoint Control Unit (MCU)
For group calls beyond 4 participants, consider integrating an SFU server to relay media streams rather than relying on full mesh.
Security
- Always enforce DTLS-SRTP encryption (mandatory in WebRTC)
- Validate SDP offers/answers on your signaling server
- Use short-lived TURN credentials (time-limited tokens)
- Implement user authentication in the signaling layer
Error Handling
pc.oniceconnectionstatechange = () => {
switch (pc.iceConnectionState) {
case 'failed':
console.error('ICE connection failed');
// Try restarting ICE
pc.restartIce();
break;
case 'disconnected':
console.log('ICE disconnected');
// Attempt to reconnect
break;
case 'closed':
console.log('ICE closed');
break;
}
};
pc.oniceerror = (event) => {
console.error('ICE error:', event);
};
pc.ontrackerror = (event) => {
console.error('Track error:', event);
};
Troubleshooting
Connection Never Establishes
Problem: PeerConnection stays in "connecting" state
Cause: ICE candidates not exchanged or STUN/TURN unreachable
Fix: 1. Check signaling server logs for message forwarding
2. Verify STUN server is reachable: `nc -vu stun.l.google.com 19302`
3. Add a TURN server as fallback for symmetric NATs
4. Ensure both peers set remote descriptions before adding ICE candidates
No Audio/Video
Problem: Remote video element shows black, no audio heard
Cause: getUserMedia permission denied or track not added to PC
Fix: 1. Check browser console for MediaDevice errors
2. Verify `getUserMedia` resolved successfully
3. Confirm `pc.addTrack()` was called for each track
4. Check that `ontrack` handler sets `srcObject` on the correct video element
Data Channel Not Opening
Problem: DataChannel.onopen never fires
Cause: Channel not negotiated before signaling
Fix: 1. Create data channel BEFORE creating the offer
2. For receiving side, implement `ondatachannel` handler
3. Verify `maxRetransmits` and `ordered` settings match on both sides
Poor Video Quality
Problem: Blurry or pixelated video
Cause: Bandwidth constraints or encoder settings
Fix: 1. Use `getStats()` to monitor bitrate and packet loss
2. Reduce resolution: `width: { ideal: 640 }, height: { ideal: 480 }`
3. Set maxBitrate via sender parameters
4. Consider using a TURN server if packet loss >5%
Browser Support
WebRTC is supported in all modern browsers:
- Chrome 56+
- Firefox 22+
- Safari 11+
- Edge 12+
- Opera 43+
Conclusion
WebRTC is the standard for real-time peer-to-peer communication in the browser. Key takeaways:
- RTCPeerConnection handles media and data channels with built-in encryption
- A signaling server (typically WebSocket-based) is required to bootstrap connections
- ICE with STUN/TURN provides NAT traversal — always configure TURN for production
- Connection monitoring via
getStats()helps detect and diagnose quality issues - Screen sharing and data channels extend WebRTC beyond simple video calls
For a deeper dive into the underlying protocols, see WebRTC Protocols. If you’re building real-time features beyond video chat, explore WebSocket for signaling and WebTransport for low-latency data streaming.
Resources
- MDN WebRTC API Documentation
- WebRTC Samples
- W3C WebRTC Specification
- WebRTC GitHub
- IETF RTCWEB Working Group
Comments