Peer-to-Peer Video with WebRTC: Signaling, RTCPeerConnection, Data Channels and TURN in Production
Key takeaways
WebRTC enables direct browser-to-browser video, audio, and data transfer without a media server. This guide covers the complete connection lifecycle — signaling, ICE negotiation, media streams, and data channels — with production patterns.
How WebRTC Works
Most real-time features on the web — chat, live dashboards, multiplayer state — flow through a server. A client sends a message, the server relays or broadcasts it, and every recipient pays the round-trip cost of hitting your infrastructure twice: once up, once down. For text this overhead is invisible. For a 1080p video stream at 30fps it is not. Routing every frame through a server doubles bandwidth cost (ingress plus egress) and adds a hop of latency that shows up as visible lag in a video call. WebRTC exists to remove that hop for media: once two browsers know how to reach each other, video, audio, and arbitrary binary data travel directly between them, encrypted end-to-end, with no server in the media path at all.
The catch is that “directly between them” is much harder to arrange than it sounds. Almost every device on the internet sits behind a NAT (Network Address Translator) — a home router, a corporate firewall, a mobile carrier’s gateway — so neither peer actually has a public IP address it can just hand to the other. WebRTC’s entire connection-setup dance exists to solve this one problem: two browsers, each hidden behind its own NAT, need to discover a network path that lets packets reach each other directly. That’s what the diagram below shows.
Peer A Signaling Server Peer B
│ │ │
│──── SDP Offer ────────────→│──── SDP Offer ───────────→│
│ │ │
│←─── SDP Answer ───────────│←─── SDP Answer ───────────│
│ │ │
│──── ICE candidates ───────→│──── ICE candidates ──────→│
│←─── ICE candidates ───────│←─── ICE candidates ───────│
│ │ │
│◄══════════ Direct P2P media/data connection ═══════════│
WebRTC has three phases, and it’s worth being precise about what travels through your server versus what doesn’t, because this is the single most common source of confusion for developers new to the API:
- Signaling — the two peers don’t know anything about each other yet, so they need a side channel to exchange metadata: what media codecs they support, what network addresses might work, and cryptographic fingerprints for the encrypted channel. This metadata is called SDP (Session Description Protocol), and it travels through your server — WebSocket, Socket.IO, or even plain HTTP polling all work, because SDP messages are small and infrequent.
- ICE — Interactive Connectivity Establishment. Each peer gathers a list of “candidate” addresses (its local LAN IP, its public IP as seen by a STUN server, and possibly a relay address from a TURN server) and exchanges them via the same signaling channel. Both sides then try every combination of local/remote candidate pairs, in parallel, and pick whichever pair actually establishes connectivity fastest.
- Connection — once ICE finds a working pair, the DTLS-SRTP encrypted media/data connection is established directly between the peers’ IP:port pairs. From this point on, your signaling server is completely uninvolved — it could go offline and an active call would keep running.
The mental model that trips people up: signaling is not part of the WebRTC standard itself. The W3C spec deliberately leaves signaling transport unspecified, because different applications have wildly different needs (a chat app already has a WebSocket connection; a browser extension might use HTTP). All the browser gives you is RTCPeerConnection, which produces the SDP and ICE data you need to send — it does not send it for you.
Signaling Server
The signaling server’s only job is to relay opaque blobs of SDP and ICE data between two specific peers — it never inspects or modifies their contents. This means you can build one with almost any transport you already have: a WebSocket server, Socket.IO (as shown below, because rooms and targeted emits map naturally onto call setup), Firebase Realtime Database, or even a REST endpoint the client polls. The choice matters less for correctness than for latency: SDP exchange happens once per call, but ICE candidates can arrive in a burst as the browser probes multiple network interfaces, so a persistent connection (WebSocket/Socket.IO) avoids the connection-setup overhead of repeated HTTP requests and keeps call setup feeling instant rather than sluggish.
Two design decisions in the implementation below are worth calling out. First, every message (offer, answer, ice-candidate) is addressed to a specific targetId rather than broadcast to the room — in a multi-peer call, SDP is peer-specific (each connection negotiates its own codecs and network path), so broadcasting would just waste bandwidth and confuse clients about which offer belongs to which connection. Second, socket.on('disconnect') broadcasts a user-left event — this is what lets other clients tear down their RTCPeerConnection objects and free resources instead of leaking a stale connection object per disconnected peer.
// server.ts
import { Server } from 'socket.io'
import { createServer } from 'http'
const httpServer = createServer()
const io = new Server(httpServer, {
cors: { origin: '*' }
})
io.on('connection', (socket) => {
console.log('Client connected:', socket.id)
// Join a room
socket.on('join-room', (roomId: string) => {
socket.join(roomId)
socket.to(roomId).emit('user-joined', socket.id)
console.log(`${socket.id} joined room ${roomId}`)
})
// Forward SDP offer to the target peer
socket.on('offer', ({ targetId, offer }: { targetId: string; offer: RTCSessionDescriptionInit }) => {
socket.to(targetId).emit('offer', { fromId: socket.id, offer })
})
// Forward SDP answer
socket.on('answer', ({ targetId, answer }: { targetId: string; answer: RTCSessionDescriptionInit }) => {
socket.to(targetId).emit('answer', { fromId: socket.id, answer })
})
// Forward ICE candidates
socket.on('ice-candidate', ({ targetId, candidate }: { targetId: string; candidate: RTCIceCandidateInit }) => {
socket.to(targetId).emit('ice-candidate', { fromId: socket.id, candidate })
})
socket.on('disconnect', () => {
socket.broadcast.emit('user-left', socket.id)
})
})
httpServer.listen(3001, () => console.log('Signaling server on port 3001'))
A subtlety that catches teams building their first group call: this disconnect handler broadcasts to every connected client on the server, not just the room the disconnecting user was in. That’s fine for a demo with one room, but in production you should track which room each socket belongs to (Socket.IO exposes socket.rooms) and scope the broadcast with socket.to(roomId).emit(...) — otherwise users in unrelated calls receive spurious user-left events for peers they’ve never heard of, and any code that does peers.delete(remoteId) on that event will silently no-op, masking a bug that only shows up under concurrent load.
// server.ts
import { Server } from 'socket.io'
import { createServer } from 'http'
const httpServer = createServer()
const io = new Server(httpServer, {
cors: { origin: '*' }
})
io.on('connection', (socket) => {
console.log('Client connected:', socket.id)
// Join a room
socket.on('join-room', (roomId: string) => {
socket.join(roomId)
socket.to(roomId).emit('user-joined', socket.id)
console.log(`${socket.id} joined room ${roomId}`)
})
// Forward SDP offer to the target peer
socket.on('offer', ({ targetId, offer }: { targetId: string; offer: RTCSessionDescriptionInit }) => {
socket.to(targetId).emit('offer', { fromId: socket.id, offer })
})
// Forward SDP answer
socket.on('answer', ({ targetId, answer }: { targetId: string; answer: RTCSessionDescriptionInit }) => {
socket.to(targetId).emit('answer', { fromId: socket.id, answer })
})
// Forward ICE candidates
socket.on('ice-candidate', ({ targetId, candidate }: { targetId: string; candidate: RTCIceCandidateInit }) => {
socket.to(targetId).emit('ice-candidate', { fromId: socket.id, candidate })
})
socket.on('disconnect', () => {
socket.broadcast.emit('user-left', socket.id)
})
})
httpServer.listen(3001, () => console.log('Signaling server on port 3001'))
RTCPeerConnection — Core API
RTCPeerConnection is the browser object that does the actual heavy lifting: gathering ICE candidates, negotiating codecs, encrypting media with DTLS-SRTP, and exposing the resulting streams. Everything else in this guide is glue code around this one object. Understanding its configuration and event model is the difference between a call that “usually works on my laptop” and one that reliably connects across corporate NATs, mobile carriers, and consumer routers.
The iceServers array passed to the constructor is where NAT traversal strategy is configured. STUN and TURN look similar in this config (both are just URLs) but solve completely different problems, and conflating them is the most common cause of “works on my network, fails on my coworker’s” bugs:
- STUN (
stun:) is a discovery protocol. A peer sends a UDP packet to the STUN server, and the server replies with “here’s the public IP:port I saw this packet come from.” That tells the peer its own public-facing address as the NAT sees it, which it can then offer as an ICE candidate. STUN servers are cheap to run (Google’s are free and public) because they do no work beyond echoing back an address — no media ever touches them. - TURN (
turn:/turns:) is a relay. When both peers are behind NATs that don’t allow any inbound connection to be predicted (symmetric NAT, common on carrier-grade mobile networks and some corporate firewalls), no direct path exists no matter how many candidates are exchanged. TURN solves this by having both peers connect outbound to a relay server, which then forwards every packet between them. This makes TURN both the connection of last resort and the most expensive: unlike STUN, all your call’s actual bandwidth flows through it, so it must be provisioned and typically metered like the media server you were trying to avoid needing.
In practice, expect STUN alone to succeed for a majority of consumer connections (both peers on typical home routers using cone NAT), but any production deployment that can’t tolerate a fraction of calls simply failing needs a TURN server configured as a fallback — there is no way to know in advance which peers will need it.
// webrtc.ts — WebRTC peer connection manager
const ICE_SERVERS = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }, // free Google STUN
{ urls: 'stun:stun1.l.google.com:19302' },
// TURN server (required for symmetric NAT)
// {
// urls: 'turn:your-turn-server.com:3478',
// username: 'username',
// credential: 'password',
// },
],
}
async function createPeerConnection(
onIceCandidate: (candidate: RTCIceCandidate) => void,
onTrack: (streams: readonly MediaStream[]) => void
): Promise<RTCPeerConnection> {
const pc = new RTCPeerConnection(ICE_SERVERS)
// Send ICE candidates to the remote peer via signaling
pc.onicecandidate = (event) => {
if (event.candidate) {
onIceCandidate(event.candidate)
}
}
pc.oniceconnectionstatechange = () => {
console.log('ICE state:', pc.iceConnectionState)
// 'checking' → 'connected' → 'completed'
// 'failed' → try restart ICE or reconnect
}
pc.onconnectionstatechange = () => {
console.log('Connection state:', pc.connectionState)
}
// Remote media tracks received
pc.ontrack = (event) => {
onTrack(event.streams)
}
return pc
}
The two state-change handlers deserve attention because they track genuinely different things, and conflating them leads to connection-health bugs. oniceconnectionstatechange reflects the low-level ICE layer — it moves through checking (probing candidate pairs), to connected (a working pair found), to completed (all candidate checks finished), and critically can drop to failed mid-call if a network path stops working (a peer’s Wi-Fi drops or a mobile connection hands off between towers). onconnectionstatechange is a higher-level aggregate that also factors in DTLS handshake state, so it’s usually the one you want to drive UI (“Reconnecting…” banners) off of. In production code, an ICE state of failed is the trigger to call pc.restartIce() (or renegotiate with iceRestart: true in createOffer) rather than tearing down and rebuilding the whole RTCPeerConnection — a full rebuild loses the accumulated codec negotiation state and is noticeably slower to recover than an ICE restart, which just re-runs candidate gathering on the existing connection.
Video Call — Complete Example
Putting signaling and RTCPeerConnection together produces the full call flow, but the piece that’s easy to gloss over is who creates the offer and why the answer/offer distinction matters at all. WebRTC negotiation follows a variant of the “offer/answer model” from SDP (originally defined for SIP telephony, reused here). Whichever peer initiates first calls createOffer(), sets it as its own local description, and sends it over signaling. The receiving peer sets that same SDP as its remote description, calls createAnswer() (which is only valid after a remote offer has been set — this ordering is enforced by the API and will throw if violated), and sends the answer back. Once each side has both its local and remote description set, the connection has a shared understanding of codecs, and ICE negotiation can complete.
The example below handles the common “room” pattern: when a new peer joins, the existing peer that receives the user-joined event is the one that creates the offer, not the new arrival. This detail matters in multi-peer meshes — if both sides tried to initiate simultaneously (a scenario called “glare”), you’d end up with two competing offers and need tie-breaking logic to resolve which one wins. Having a clear, deterministic initiator (whoever was already in the room) sidesteps glare entirely for the two-party case.
// client.ts
import { io } from 'socket.io-client'
const socket = io('http://localhost:3001')
let localStream: MediaStream
let peers: Map<string, RTCPeerConnection> = new Map()
// Step 1: Get local media
async function startLocalStream(): Promise<MediaStream> {
localStream = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720, facingMode: 'user' },
audio: { echoCancellation: true, noiseSuppression: true },
})
const localVideo = document.getElementById('local-video') as HTMLVideoElement
localVideo.srcObject = localStream
localVideo.muted = true // prevent echo
return localStream
}
// Step 2: Join room and create connections
async function joinRoom(roomId: string) {
await startLocalStream()
socket.emit('join-room', roomId)
}
// Step 3: When a new user joins — create offer
socket.on('user-joined', async (remoteId: string) => {
const pc = await createPeerConnection(remoteId)
// Add local tracks to connection
localStream.getTracks().forEach(track => pc.addTrack(track, localStream))
// Create and send offer
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
socket.emit('offer', { targetId: remoteId, offer })
})
// Step 4: Receive offer — create answer
socket.on('offer', async ({ fromId, offer }: { fromId: string; offer: RTCSessionDescriptionInit }) => {
const pc = await createPeerConnection(fromId)
localStream.getTracks().forEach(track => pc.addTrack(track, localStream))
await pc.setRemoteDescription(offer)
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
socket.emit('answer', { targetId: fromId, answer })
})
// Step 5: Receive answer
socket.on('answer', async ({ fromId, answer }: { fromId: string; answer: RTCSessionDescriptionInit }) => {
const pc = peers.get(fromId)
await pc?.setRemoteDescription(answer)
})
// Step 6: Exchange ICE candidates
socket.on('ice-candidate', async ({ fromId, candidate }: { fromId: string; candidate: RTCIceCandidateInit }) => {
const pc = peers.get(fromId)
await pc?.addIceCandidate(candidate)
})
async function createPeerConnection(remoteId: string): Promise<RTCPeerConnection> {
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
peers.set(remoteId, pc)
pc.onicecandidate = ({ candidate }) => {
if (candidate) socket.emit('ice-candidate', { targetId: remoteId, candidate })
}
pc.ontrack = ({ streams }) => {
// Add remote video
let remoteVideo = document.getElementById(`video-${remoteId}`) as HTMLVideoElement
if (!remoteVideo) {
remoteVideo = document.createElement('video')
remoteVideo.id = `video-${remoteId}`
remoteVideo.autoplay = true
remoteVideo.playsInline = true
document.getElementById('remote-videos')!.appendChild(remoteVideo)
}
remoteVideo.srcObject = streams[0]
}
return pc
}
// Cleanup on user leave
socket.on('user-left', (remoteId: string) => {
peers.get(remoteId)?.close()
peers.delete(remoteId)
document.getElementById(`video-${remoteId}`)?.remove()
})
Data Channels
Send arbitrary data peer-to-peer (no server involved):
// Create data channel on the offering side
const pc = new RTCPeerConnection(config)
const dataChannel = pc.createDataChannel('chat', {
ordered: true, // guaranteed order (like TCP)
// ordered: false, // unordered (like UDP, lower latency)
})
dataChannel.onopen = () => {
console.log('Data channel open')
dataChannel.send(JSON.stringify({ type: 'hello', text: 'Hi!' }))
}
dataChannel.onmessage = (event) => {
const msg = JSON.parse(event.data)
console.log('Received:', msg)
}
// Receive data channel on the answering side
pc.ondatachannel = (event) => {
const channel = event.channel
channel.onopen = () => console.log('Channel ready')
channel.onmessage = (e) => {
const msg = JSON.parse(e.data)
displayMessage(msg)
}
}
// Send different data types
dataChannel.send('plain text')
dataChannel.send(JSON.stringify({ type: 'message', text: 'Hello' }))
// Send binary (files, images)
const file = new File(['content'], 'test.txt')
const buffer = await file.arrayBuffer()
dataChannel.send(buffer)
Screen Sharing
async function startScreenShare(pc: RTCPeerConnection, localStream: MediaStream) {
// Get screen stream
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
displaySurface: 'monitor', // 'window', 'browser', 'monitor'
frameRate: 30,
},
audio: true, // capture system audio (if supported)
})
const screenTrack = screenStream.getVideoTracks()[0]
// Replace video track in connection
const sender = pc.getSenders().find(s => s.track?.kind === 'video')
if (sender) await sender.replaceTrack(screenTrack)
// Restore camera when screen share stops
screenTrack.onended = async () => {
const cameraTrack = localStream.getVideoTracks()[0]
if (sender) await sender.replaceTrack(cameraTrack)
console.log('Screen share stopped, back to camera')
}
return screenStream
}
Media Controls
// Mute/unmute audio
function toggleAudio(stream: MediaStream) {
stream.getAudioTracks().forEach(track => {
track.enabled = !track.enabled
console.log('Audio:', track.enabled ? 'on' : 'off')
})
}
// Enable/disable video
function toggleVideo(stream: MediaStream) {
stream.getVideoTracks().forEach(track => {
track.enabled = !track.enabled
console.log('Video:', track.enabled ? 'on' : 'off')
})
}
// Change video quality
async function changeResolution(pc: RTCPeerConnection, width: number, height: number) {
const sender = pc.getSenders().find(s => s.track?.kind === 'video')
if (!sender) return
const params = sender.getParameters()
params.encodings = [{ maxBitrate: 500000, scaleResolutionDownBy: 1 }]
await sender.setParameters(params)
}
// Get connection statistics
async function getStats(pc: RTCPeerConnection) {
const stats = await pc.getStats()
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
console.log('Video received:', {
bitrate: report.bytesReceived,
packets: report.packetsReceived,
lost: report.packetsLost,
fps: report.framesPerSecond,
})
}
})
}
Production Considerations
TURN Server (Coturn)
# Install on Ubuntu
sudo apt install coturn
# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
listening-ip=0.0.0.0
external-ip=YOUR_PUBLIC_IP
realm=your-domain.com
user=username:password
lt-cred-mech
fingerprint
no-loopback-peers
no-multicast-peers
Time-limited TURN credentials (security)
// Never expose static TURN credentials in frontend code
// Generate short-lived credentials server-side
import crypto from 'crypto'
function generateTurnCredentials(username: string, ttlSeconds = 3600) {
const expiry = Math.floor(Date.now() / 1000) + ttlSeconds
const temporaryUser = `${expiry}:${username}`
const credential = crypto
.createHmac('sha1', process.env.TURN_SECRET!)
.update(temporaryUser)
.digest('base64')
return {
urls: ['turn:your-turn.example.com:3478'],
username: temporaryUser,
credential,
}
}
// API endpoint to get TURN credentials
app.get('/api/turn-credentials', requireAuth, (req, res) => {
const creds = generateTurnCredentials(req.user.id)
res.json({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }, creds] })
})
WebRTC vs WebSocket vs SSE
| WebRTC | WebSocket | SSE | |
|---|---|---|---|
| Media | Peer-to-peer | Server relay | Server push only |
| Latency | Lowest (direct) | Low | Low |
| Server load | None (media) | High | Medium |
| NAT traversal | Required | Not needed | Not needed |
| Best for | Video/audio calls, P2P data | Chat, gaming, collaboration | Notifications, feeds |