Web RTC Developer Guide
Practical WebRTC Guide — Build a Simple Video & Data App
A step-by-step overview of WebRTC concepts, the essential APIs, a minimal signaling server (Node + Socket.io), and copy-ready JavaScript — highlighted like VS Code so your blog readers can copy easily.
What is WebRTC (in plain words)?
WebRTC (Web Real-Time Communication) enables browsers (and native apps) to send audio, video, and arbitrary data directly between peers with low latency. It handles media capture, peer connections, NAT traversal (ICE), and secure transport (DTLS/SRTP). You still need a signaling channel to exchange connection metadata (offers, answers, ICE candidates).
Key takeaways: WebRTC gives you real-time media + data channels between clients. Signaling is app-specific; STUN/TURN servers help with connectivity.
Architecture — how it fits together
- getUserMedia(): capture camera/mic streams.
- RTCPeerConnection: create peer connections and attach tracks.
- ICE (STUN/TURN): find a network path between peers.
- Signaling: your server transports SDP and ICE candidates (example: WebSocket, Socket.io).
- RTCDataChannel: for text, files, or game state (low-latency).
Quick demo — what we'll build
Users enter a room id. The first user waits; the second user triggers the offer/answer exchange. This example uses one video tag for local preview and one for remote.
Client (browser) — HTML + JS
<!-- index.html (client) -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WebRTC Demo</title>
</head>
<body>
<input id="roomInput" placeholder="room id" />
<button id="joinBtn">Join Room</button>
<div>
<video id="localVideo" autoplay muted playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>
</div>
<!-- Socket.io client (from your server) -->
<script src="/socket.io/socket.io.js"></script>
<script>
// client.js (inline for brevity)
(function(){
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const joinBtn = document.getElementById('joinBtn');
const roomInput = document.getElementById('roomInput');
const configuration = {
iceServers: [
{ urls: ['stun:stun.l.google.com:19302'] }
// add TURN servers for production
]
};
let localStream = null;
let pc = null;
let socket = null;
let roomId = null;
async function startLocalStream() {
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localVideo.srcObject = localStream;
}
function createPeerConnection() {
pc = new RTCPeerConnection(configuration);
// Add local tracks
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
// When a remote track arrives, attach to remoteVideo
pc.ontrack = (evt) => {
// first stream only
remoteVideo.srcObject = evt.streams[0];
};
pc.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('ice-candidate', { roomId, candidate: event.candidate });
}
};
// optional: data channel
pc.ondatachannel = (evt) => {
const dc = evt.channel;
dc.onmessage = e => console.log('Data channel message:', e.data);
};
return pc;
}
joinBtn.onclick = async () => {
if (!roomInput.value) return alert('Enter a room id');
roomId = roomInput.value;
await startLocalStream();
socket = io(); // connects to same host by default
socket.on('connect', () => {
socket.emit('join', roomId);
});
// Another user is already in the room -> create offer
socket.on('ready', async () => {
pc = createPeerConnection();
// Create a data channel for demo
const dc = pc.createDataChannel('chat');
dc.onopen = () => console.log('Data channel open');
dc.onmessage = e => console.log('Received:', e.data);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('offer', { roomId, sdp: pc.localDescription });
});
// Incoming offer
socket.on('offer', async ({ sdp }) => {
pc = createPeerConnection();
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('answer', { roomId, sdp: pc.localDescription });
});
// Incoming answer
socket.on('answer', async ({ sdp }) => {
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
});
// ICE candidates
socket.on('ice-candidate', async ({ candidate }) => {
try {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (err) {
console.warn('Error adding received ice candidate', err);
}
});
// If the room is empty, server will emit 'created' instead of 'ready'
socket.on('created', () => {
console.log('Room created, waiting for peer...');
});
};
})();
</script>
</body>
</html>
Notes: This example uses a single STUN server (Google). For production, add at least one TURN server (for relaying) and strict security rules.
Minimal signaling server (Node + Socket.io)
// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
// Serve static files from 'public' (where index.html lives)
app.use(express.static('public'));
io.on('connection', socket => {
console.log('socket connected', socket.id);
socket.on('join', (roomId) => {
const room = io.sockets.adapter.rooms.get(roomId);
const numClients = room ? room.size : 0;
if (numClients === 0) {
socket.join(roomId);
socket.emit('created');
} else if (numClients === 1) {
socket.join(roomId);
// notify the second peer that the room is ready (both present)
io.to(roomId).emit('ready');
} else {
// max 2 peers in this simple example
socket.emit('full');
}
});
socket.on('offer', ({ roomId, sdp }) => {
socket.to(roomId).emit('offer', { sdp });
});
socket.on('answer', ({ roomId, sdp }) => {
socket.to(roomId).emit('answer', { sdp });
});
socket.on('ice-candidate', ({ roomId, candidate }) => {
socket.to(roomId).emit('ice-candidate', { candidate });
});
socket.on('disconnect', () => {
console.log('socket disconnected', socket.id);
});
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => console.log('Server listening on', PORT));
How it works: the server only forwards signaling messages between peers in the same room. This keeps the server stateless regarding WebRTC; all heavy media flows directly peer-to-peer (unless TURN is used).
Using RTCDataChannel (simple chat)
// When offering peer:
const dc = pc.createDataChannel('chat');
dc.onopen = () => console.log('data channel open');
dc.onmessage = (e) => console.log('got message', e.data);
// On answering peer:
pc.ondatachannel = (event) => {
const remoteDC = event.channel;
remoteDC.onmessage = e => console.log('msg from offerer:', e.data);
remoteDC.onopen = () => console.log('remote data channel open');
};
// Send a message:
if (dc && dc.readyState === 'open') {
dc.send('hello from peer');
}
Data channels are ordered and reliable by default — great for chat, file metadata, or small game state updates. For low-latency (unreliable) use pc.createDataChannel('name', { ordered: false, maxRetransmits: 0 }).
STUN vs TURN (short)
- STUN: helps a peer discover its public-facing IP/port. Used when NAT traversal can be achieved directly.
- TURN: relays media when direct connection fails (e.g., symmetric NAT). TURN servers relay traffic and incur bandwidth costs.
For reliable connectivity, include a TURN server (e.g., coturn) behind authentication. Use ephemeral credentials for security in production.
Debugging & troubleshooting tips
- Open
chrome://webrtc-internalsin Chrome for detailed logs/traces. - Check browser console for SDP/ICE errors, and log candidate events.
- Verify getUserMedia permissions, and test with audio-only or video-only to isolate issues.
- If connections fail in some networks, ensure you have a TURN server enabled.
- Use simple ICE server entries until you confirm connectivity works, then optimize.
Production checklist
- Use HTTPS (getUserMedia and most browsers require secure origins).
- Use TURN servers with secure auth for public users.
- Limit room sizes and validate room ids on the server.
- Monitor CPU and memory on clients for high-res streams.
- Use adaptive bitrate and simulcast for multi-party scaling (SFU/MCU solutions like Janus, Jitsi, mediasoup).
Summary
WebRTC gives you browser-native, low-latency real-time media and data transport. Build a signaling channel to exchange offers/answers and ICE candidates, attach local media to an RTCPeerConnection, and let STUN/TURN handle connectivity. For larger group calls, look into SFU/MCU servers.