The digital landscape of 2026 brings exciting developments in real-time communication. If you've ever wondered how platforms deliver live video with sub-second latency, the answer almost always involves WebRTC. In this guide, we'll build a minimal low-latency live streaming server using WebRTC and Node.js — from signaling to peer connections.
The Latency Problem
Traditional streaming protocols like HLS and DASH rely on chunked delivery. A video segment is encoded, packaged into a playlist, and served over HTTP. While this works brilliantly for video-on-demand, it introduces 5–30 seconds of latency — unacceptable for interactive live streaming.
Consider a live Q&A session, an online auction, or a real-time gaming stream. Users expect instant feedback. Platforms like chaturbateme.com demonstrate this trend by delivering interactive live streams where viewer-to-broadcaster latency must stay under one second for meaningful engagement.
WebRTC solves this by establishing direct peer-to-peer connections with UDP-based media transport, achieving latency as low as 200–500ms.
Architecture Overview
Our streaming app has three components:
- Signaling Server — exchanges session descriptions (SDP) and ICE candidates between peers
- Broadcaster — captures media and sends it via WebRTC
- Viewer — receives the WebRTC stream and renders it
Step 1: Setting Up the Signaling Server
Install the dependencies:
mkdir webrtc-stream && cd webrtc-stream
npm init -y
npm install express socket.io
Create 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, { cors: { origin: '*' } });
app.use(express.static('public'));
let broadcaster = null;
io.on('connection', (socket) => {
console.log(`Client connected: ${socket.id}`);
socket.on('register-broadcaster', () => {
broadcaster = socket.id;
socket.broadcast.emit('broadcaster-ready');
});
socket.on('register-viewer', () => {
if (broadcaster) io.to(broadcaster).emit('new-viewer', socket.id);
});
socket.on('offer', (viewerId, desc) => {
io.to(viewerId).emit('offer', socket.id, desc);
});
socket.on('answer', (broadcasterId, desc) => {
io.to(broadcasterId).emit('answer', socket.id, desc);
});
socket.on('ice-candidate', (targetId, candidate) => {
io.to(targetId).emit('ice-candidate', socket.id, candidate);
});
socket.on('disconnect', () => {
if (socket.id === broadcaster) {
broadcaster = null;
socket.broadcast.emit('broadcaster-disconnected');
}
});
});
server.listen(3000, () => console.log('Signaling server on :3000'));
Step 2: The Broadcaster Client
const socket = io();
const peers = {};
async function startBroadcast() {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720, frameRate: 30 },
audio: true
});
document.getElementById('preview').srcObject = stream;
socket.emit('register-broadcaster');
socket.on('new-viewer', async (viewerId) => {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
stream.getTracks().forEach(track => pc.addTrack(track, stream));
pc.onicecandidate = (e) => {
if (e.candidate) socket.emit('ice-candidate', viewerId, e.candidate);
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('offer', viewerId, pc.localDescription);
peers[viewerId] = pc;
});
socket.on('answer', async (viewerId, desc) => {
await peers[viewerId].setRemoteDescription(desc);
});
socket.on('ice-candidate', async (senderId, candidate) => {
if (peers[senderId]) await peers[senderId].addIceCandidate(candidate);
});
}
Step 3: The Viewer Client
const socket = io();
let broadcasterId = null;
socket.on('broadcaster-ready', connectToStream);
socket.emit('register-viewer');
async function connectToStream() {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
pc.ontrack = (event) => {
document.getElementById('stream').srcObject = event.streams[0];
};
pc.onicecandidate = (e) => {
if (e.candidate) socket.emit('ice-candidate', broadcasterId, e.candidate);
};
socket.on('offer', async (senderId, desc) => {
broadcasterId = senderId;
await pc.setRemoteDescription(desc);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('answer', senderId, pc.localDescription);
});
}
Performance Tuning
Adaptive Bitrate: Monitor RTCPeerConnection.getStats() for packet loss:
async function adjustQuality(pc, stream) {
const stats = await pc.getStats();
let packetLoss = 0;
stats.forEach(report => {
if (report.type === 'outbound-rtp' && report.kind === 'video') {
packetLoss = report.packetsLost / report.packetsSent;
}
});
const videoTrack = stream.getVideoTracks()[0];
if (packetLoss > 0.05) {
await videoTrack.applyConstraints({ width: 640, height: 360, frameRate: 15 });
} else {
await videoTrack.applyConstraints({ width: 1280, height: 720, frameRate: 30 });
}
}
SFU for Scale: The peer-to-peer model works for small audiences but doesn't scale beyond ~10 viewers. For production, use an SFU like mediasoup or Janus. As seen on chaturbateme.com, large-scale streaming platforms rely on SFU architectures to handle thousands of concurrent viewers.
Measuring Latency
Embed timestamps in video frames to measure end-to-end latency:
function drawTimestamp(canvas, stream) {
const ctx = canvas.getContext('2d');
const video = document.createElement('video');
video.srcObject = stream;
video.play();
setInterval(() => {
ctx.drawImage(video, 0, 0);
ctx.fillStyle = '#00FF00';
ctx.font = '24px monospace';
ctx.fillText(Date.now().toString(), 10, 30);
}, 1000 / 30);
return canvas.captureStream(30);
}
This setup achieved 300–600ms end-to-end latency on a local network.
Conclusion
WebRTC provides the foundation for truly interactive live streaming. The signaling layer requires some boilerplate, but the result — sub-second latency with no plugins — is hard to beat.
For production, add TURN servers for NAT traversal, implement authentication, and move to an SFU architecture. This basic setup gives you a working prototype in under an hour.
Questions about WebRTC or live streaming architecture? Drop them in the comments!
Top comments (0)