The modern web user expects immediacy. Whether it's a stock ticker ticking in real time, a collaborative whiteboard updating as a teammate draws, or a live chat where messages appear without hitting refresh — latency is the enemy of great UX.
In this post, we'll deep-dive into WebSockets and WebRTC: the two workhorses of real-time web development. We'll walk through concepts, architecture decisions, production-grade code, and the mistakes that trip up even experienced engineers.
By the end, you'll have the mental model and the practical toolkit to build live dashboards, collaborative features, and analytics pipelines that scale.
Why Real-Time Matters in 2026
Real-time is no longer a "nice to have." It's a core product expectation:
- Collaboration tools (Figma, Notion, Linear) feel broken without live presence and updates.
- Analytics dashboards need to surface anomalies as they happen, not five minutes later.
- Customer support chat loses trust if there's a visible delay.
- Multiplayer or co-editing features are table stakes for modern SaaS.
The HTTP request/response cycle wasn't designed for this. It's pull-based: the client asks, the server answers. Real-time requires push: the server (or a peer) sends data the moment something changes.
Enter WebSockets and WebRTC.
Understanding the Two Protocols
WebSockets: Persistent, Bidirectional Channels
A WebSocket connection starts as an HTTP request and upgrades to a long-lived TCP connection. Once established, both the client and server can send messages freely — no polling, no repeated handshakes.
Client Server
|--- HTTP Upgrade Request ------->|
|<-- 101 Switching Protocols ------|
|<======= WebSocket Frames =======>| (bidirectional, persistent)
Best suited for:
- Live dashboards and analytics
- Chat and notifications
- Collaborative editing (with CRDTs or OT)
- Real-time game state sync
- Financial data feeds
WebRTC: Peer-to-Peer Media and Data
WebRTC (Web Real-Time Communication) enables direct browser-to-browser communication — video, audio, and arbitrary data — without routing everything through a server. It uses UDP under the hood for low-latency delivery and includes built-in encryption (DTLS-SRTP).
Browser A STUN/TURN Server Browser B
|------- ICE Candidates -------->|<------ ICE Candidates ---|
|<============ Direct P2P Data Channel ===================>|
Best suited for:
- Video/audio calls
- Screen sharing
- P2P file transfer
- Low-latency collaborative cursors
- Real-time gaming with direct peer sync
Key distinction: WebSockets route through your server (client ↔ server ↔ client). WebRTC goes peer-to-peer (browser ↔ browser) after signaling. Choose based on your latency, scaling, and topology needs.
Part 1: Building a Live Analytics Dashboard with WebSockets
Architecture Overview
┌─────────────────────────────────────────────────────┐
│ Client (Browser) │
│ WebSocket Client → React State → Live Chart UI │
└────────────────────────┬────────────────────────────┘
│ ws://
┌────────────────────────▼────────────────────────────┐
│ WebSocket Server (Node.js) │
│ Connection Manager → Event Bus → Data Broadcaster │
└────────────────────────┬────────────────────────────┘
│
┌────────────────────────▼────────────────────────────┐
│ Data Sources (Redis Pub/Sub, DB) │
└─────────────────────────────────────────────────────┘
Setting Up the WebSocket Server
We'll use the ws library — lean, fast, and production-proven.
npm install ws redis
// server/websocket.js
import { WebSocketServer, WebSocket } from 'ws';
import { createClient } from 'redis';
const wss = new WebSocketServer({ port: 8080 });
const redisSubscriber = createClient();
await redisSubscriber.connect();
// Track connected clients by room/channel
const rooms = new Map(); // roomId → Set<WebSocket>
function broadcast(roomId, message) {
const clients = rooms.get(roomId);
if (!clients) return;
const payload = JSON.stringify(message);
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(payload);
}
}
}
wss.on('connection', (ws, req) => {
const url = new URL(req.url, 'http://localhost');
const roomId = url.searchParams.get('room') ?? 'global';
// Register client in room
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId).add(ws);
console.log(`Client joined room: ${roomId}. Total: ${rooms.get(roomId).size}`);
// Handle incoming messages from client
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
handleClientMessage(ws, roomId, message);
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
}
});
// Cleanup on disconnect
ws.on('close', () => {
rooms.get(roomId)?.delete(ws);
if (rooms.get(roomId)?.size === 0) rooms.delete(roomId);
console.log(`Client left room: ${roomId}`);
});
// Heartbeat to detect zombie connections
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
// Ping all clients every 30s to detect dead connections
const heartbeat = setInterval(() => {
for (const [roomId, clients] of rooms.entries()) {
for (const ws of clients) {
if (!ws.isAlive) {
ws.terminate();
clients.delete(ws);
continue;
}
ws.isAlive = false;
ws.ping();
}
if (clients.size === 0) rooms.delete(roomId);
}
}, 30_000);
wss.on('close', () => clearInterval(heartbeat));
// Subscribe to Redis pub/sub for analytics events
await redisSubscriber.subscribe('analytics:events', (message) => {
const event = JSON.parse(message);
broadcast(event.roomId ?? 'global', {
type: 'analytics_update',
data: event,
});
});
function handleClientMessage(ws, roomId, message) {
switch (message.type) {
case 'subscribe':
// Client can subscribe to specific metric streams
ws.subscribedMetrics = message.metrics ?? [];
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }));
break;
default:
console.warn('Unknown message type:', message.type);
}
}
The React Client: Connecting and Displaying Live Data
// hooks/useWebSocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';
type WSMessage = {
type: string;
data?: unknown;
};
type UseWebSocketOptions = {
url: string;
onMessage: (msg: WSMessage) => void;
reconnectDelay?: number;
};
export function useWebSocket({ url, onMessage, reconnectDelay = 3000 }: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting');
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
const connect = useCallback(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
setStatus('connecting');
ws.onopen = () => setStatus('open');
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as WSMessage;
onMessage(msg);
} catch {
console.error('Failed to parse WS message');
}
};
ws.onclose = () => {
setStatus('closed');
// Exponential backoff could be added here
reconnectTimer.current = setTimeout(connect, reconnectDelay);
};
ws.onerror = (err) => console.error('WebSocket error:', err);
}, [url, onMessage, reconnectDelay]);
useEffect(() => {
connect();
return () => {
clearTimeout(reconnectTimer.current);
wsRef.current?.close();
};
}, [connect]);
const send = useCallback((msg: WSMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(msg));
}
}, []);
return { status, send };
}
// components/LiveDashboard.tsx
import { useState, useCallback } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
type MetricPoint = { timestamp: number; value: number; metric: string };
export function LiveDashboard() {
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
const [connectionStatus, setConnectionStatus] = useState('connecting');
const handleMessage = useCallback((msg: { type: string; data?: MetricPoint }) => {
if (msg.type === 'analytics_update' && msg.data) {
setMetrics((prev) => {
const updated = [...prev, msg.data!];
// Keep last 100 points per metric
return updated.slice(-100);
});
}
}, []);
const { status, send } = useWebSocket({
url: `wss://your-server.com/ws?room=analytics`,
onMessage: handleMessage,
});
return (
<div className="dashboard">
<header>
<h1>Live Analytics</h1>
<span className={`status status--${status}`}>{status}</span>
</header>
<button onClick={() => send({ type: 'subscribe', metrics: ['cpu', 'memory', 'rps'] })}>
Subscribe to System Metrics
</button>
<div className="metrics-grid">
{/* Render your chart library (Recharts, Victory, etc.) with `metrics` */}
<pre>{JSON.stringify(metrics.slice(-5), null, 2)}</pre>
</div>
</div>
);
}
Part 2: Real-Time Collaboration with WebRTC
How WebRTC Signaling Works
WebRTC is peer-to-peer, but peers need to find each other first. This is done via a signaling server — a simple WebSocket or HTTP endpoint that relays session descriptions and ICE candidates between peers. Once connected, all data flows directly between browsers.
Peer A Signaling Server Peer B
|--- createOffer() ------>| |
| |------ offer SDP -------->|
| |<----- answer SDP --------|
|<---- answer() ----------| |
|--- ICE candidates ----->|--- ICE candidates ------->|
|<======= Direct P2P Data Channel ===================>|
Building a Collaborative Cursor Feature with WebRTC Data Channels
// collaboration/peer.js
export class CollaborationPeer {
constructor(signalingUrl, roomId, userId) {
this.userId = userId;
this.roomId = roomId;
this.peers = new Map(); // peerId → RTCPeerConnection
this.channels = new Map(); // peerId → RTCDataChannel
this.signaling = new WebSocket(`${signalingUrl}?room=${roomId}&user=${userId}`);
this.signaling.onmessage = (e) => this.handleSignal(JSON.parse(e.data));
this.onCursorMove = null; // Callback for cursor updates
}
async connectToPeer(peerId) {
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
// Add TURN server for NAT traversal in production
{
urls: 'turn:your-turn-server.com:3478',
username: 'user',
credential: 'password',
},
],
});
this.peers.set(peerId, pc);
// Create data channel for cursor/presence data
const channel = pc.createDataChannel('presence', {
ordered: false, // OK for cursor positions (we want latest, not all)
maxRetransmits: 0, // Drop stale cursor positions
});
this.setupChannel(channel, peerId);
this.channels.set(peerId, channel);
pc.onicecandidate = ({ candidate }) => {
if (candidate) {
this.send({ type: 'ice-candidate', to: peerId, candidate });
}
};
pc.ondatachannel = ({ channel }) => {
this.setupChannel(channel, peerId);
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this.send({ type: 'offer', to: peerId, sdp: offer });
}
setupChannel(channel, peerId) {
channel.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.type === 'cursor' && this.onCursorMove) {
this.onCursorMove({ peerId, x: msg.x, y: msg.y, color: msg.color });
}
};
}
async handleSignal(msg) {
const { type, from } = msg;
switch (type) {
case 'peer-joined':
await this.connectToPeer(from);
break;
case 'offer': {
const pc = new RTCPeerConnection({ iceServers: [...] });
this.peers.set(from, pc);
pc.ondatachannel = ({ channel }) => this.setupChannel(channel, from);
pc.onicecandidate = ({ candidate }) => {
if (candidate) this.send({ type: 'ice-candidate', to: from, candidate });
};
await pc.setRemoteDescription(msg.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.send({ type: 'answer', to: from, sdp: answer });
break;
}
case 'answer':
await this.peers.get(from)?.setRemoteDescription(msg.sdp);
break;
case 'ice-candidate':
await this.peers.get(from)?.addIceCandidate(msg.candidate);
break;
}
}
broadcastCursor(x, y, color) {
const payload = JSON.stringify({ type: 'cursor', x, y, color });
for (const [, channel] of this.channels) {
if (channel.readyState === 'open') {
channel.send(payload);
}
}
}
send(msg) {
if (this.signaling.readyState === WebSocket.OPEN) {
this.signaling.send(JSON.stringify(msg));
}
}
destroy() {
for (const pc of this.peers.values()) pc.close();
this.signaling.close();
}
}
// components/CollaborativeCanvas.tsx
import { useEffect, useRef, useState } from 'react';
import { CollaborationPeer } from '../collaboration/peer';
type Cursor = { peerId: string; x: number; y: number; color: string };
const PEER_COLORS = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'];
export function CollaborativeCanvas({ roomId, userId }: { roomId: string; userId: string }) {
const [cursors, setCursors] = useState<Map<string, Cursor>>(new Map());
const peerRef = useRef<CollaborationPeer>();
useEffect(() => {
const peer = new CollaborationPeer(
'wss://your-signaling-server.com',
roomId,
userId
);
peer.onCursorMove = ({ peerId, x, y, color }) => {
setCursors((prev) => new Map(prev).set(peerId, { peerId, x, y, color }));
};
peerRef.current = peer;
return () => peer.destroy();
}, [roomId, userId]);
const handleMouseMove = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
peerRef.current?.broadcastCursor(x, y, PEER_COLORS[0]);
};
return (
<div
className="canvas"
style={{ position: 'relative', width: '100%', height: '600px', background: '#1a1a2e' }}
onMouseMove={handleMouseMove}
>
{Array.from(cursors.values()).map((cursor) => (
<div
key={cursor.peerId}
style={{
position: 'absolute',
left: `${cursor.x}%`,
top: `${cursor.y}%`,
width: 12,
height: 12,
borderRadius: '50%',
background: cursor.color,
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
transition: 'left 50ms linear, top 50ms linear',
}}
/>
))}
<p style={{ color: '#fff', padding: '1rem' }}>Move your cursor — collaborators see it live!</p>
</div>
);
}
Real-World System Design Patterns
Pattern 1: Fan-Out with Redis Pub/Sub
For dashboards serving thousands of concurrent users, don't broadcast from app memory — use Redis Pub/Sub to fan out events across multiple WebSocket server instances.
┌─────────┐ publish ┌───────────┐ subscribe ┌──────────────┐
│ Producer│ ─────────────► │ Redis │ ──────────────► │ WS Server #1 │─► Clients
│ Service │ │ Pub/Sub │ ──────────────► │ WS Server #2 │─► Clients
└─────────┘ └───────────┘ │ WS Server #3 │─► Clients
└──────────────┘
// publisher.js — runs in your data pipeline
import { createClient } from 'redis';
const publisher = createClient();
await publisher.connect();
// Publish a metric event
async function emitMetric(metric, value, roomId = 'global') {
await publisher.publish(
'analytics:events',
JSON.stringify({ metric, value, roomId, timestamp: Date.now() })
);
}
// Example: emit metrics every second
setInterval(() => {
emitMetric('requests_per_second', Math.floor(Math.random() * 5000), 'global');
emitMetric('error_rate', Math.random() * 0.05, 'global');
}, 1000);
Pattern 2: CRDT-Based Collaborative State
For conflict-free collaborative editing (think Google Docs), use CRDTs (Conflict-Free Replicated Data Types). Libraries like Yjs handle the hard parts:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const doc = new Y.Doc();
// Connect to a y-websocket server for syncing
const provider = new WebsocketProvider(
'wss://your-yjs-server.com',
'my-room',
doc
);
// Shared map — all peers see the same state, conflicts auto-resolved
const sharedState = doc.getMap('dashboard');
// Set a value (broadcasts to all peers automatically)
sharedState.set('selectedMetric', 'cpu_usage');
// Observe changes from any peer
sharedState.observe((event) => {
console.log('State changed:', event.changes.keys);
});
Pattern 3: Presence Tracking
Show who's online in your collaborative app:
// server/presence.js
const presence = new Map(); // userId → { lastSeen, metadata }
function updatePresence(userId, metadata) {
presence.set(userId, { lastSeen: Date.now(), ...metadata });
broadcastPresence();
}
function getOnlineUsers() {
const threshold = Date.now() - 10_000; // 10s timeout
return [...presence.entries()]
.filter(([, v]) => v.lastSeen > threshold)
.map(([userId, data]) => ({ userId, ...data }));
}
function broadcastPresence() {
broadcast('global', {
type: 'presence_update',
users: getOnlineUsers(),
});
}
// Clean up stale users every 5s
setInterval(() => {
const threshold = Date.now() - 10_000;
for (const [userId, data] of presence.entries()) {
if (data.lastSeen < threshold) presence.delete(userId);
}
broadcastPresence();
}, 5_000);
Best Practices
WebSocket Best Practices
- Always implement heartbeats. TCP connections can silently die. Ping/pong every 25–30 seconds reveals zombie connections before they accumulate.
- Use binary frames for high-frequency data. If you're pushing 60 fps telemetry, use
MessagePackorprotobufinstead of JSON. It can cut message size by 40–60%. - Throttle client-originated messages. Rate-limit incoming messages server-side to prevent abuse and resource exhaustion.
- Authenticate at connection time. Validate JWT tokens during the HTTP upgrade handshake — it's your last easy checkpoint.
- Use subprotocols for versioning. Pass a subprotocol header (
Sec-WebSocket-Protocol) to allow graceful API versioning. - Implement reconnection with exponential backoff. Don't hammer the server on disconnect. Back off: 1s, 2s, 4s, 8s, cap at 30s.
WebRTC Best Practices
- Always deploy a TURN server. STUN alone fails behind symmetric NATs and corporate firewalls — which covers a huge chunk of enterprise users. Use
coturnor a managed service like Twilio or Metered.ca. - Use
maxRetransmits: 0for real-time positional data. Stale cursor positions are worse than dropped ones. Only enable reliability for critical state. - Handle ICE connection state changes.
iceConnectionStatecan enterfailed— trigger ICE restart withpc.restartIce()rather than dropping the connection. - Monitor
getStats()in production. WebRTC'sRTCPeerConnection.getStats()gives you packet loss, jitter, and RTT — use them to degrade gracefully. - Limit data channel concurrency. Each RTCPeerConnection is a resource. For many-to-many, use SFU (Selective Forwarding Unit) architecture instead of a full mesh.
Common Mistakes to Avoid
❌ Mistake 1: Not Handling Reconnection
// BAD — no reconnection logic
const ws = new WebSocket(url);
ws.onclose = () => console.log('Connection closed');
// GOOD — reconnect with backoff
function connect(attempt = 0) {
const ws = new WebSocket(url);
ws.onclose = () => {
const delay = Math.min(1000 * 2 ** attempt, 30_000);
setTimeout(() => connect(attempt + 1), delay);
};
return ws;
}
❌ Mistake 2: Sending Too Much Data Too Often
// BAD — sending every mousemove event (100s per second)
canvas.addEventListener('mousemove', (e) => {
ws.send(JSON.stringify({ x: e.clientX, y: e.clientY }));
});
// GOOD — throttle to 30 updates per second
import { throttle } from 'lodash-es';
const sendCursor = throttle((x, y) => {
ws.send(JSON.stringify({ type: 'cursor', x, y }));
}, 33); // ~30fps
canvas.addEventListener('mousemove', (e) => sendCursor(e.clientX, e.clientY));
❌ Mistake 3: No Authentication on WebSocket Connections
// BAD — open WebSocket with no auth
wss.on('connection', (ws) => {
// Anyone can connect and receive data
});
// GOOD — verify token on upgrade
wss.on('headers', (headers, req) => {
const token = new URL(req.url, 'http://x').searchParams.get('token');
if (!verifyJWT(token)) {
// Reject the connection during upgrade
headers.push('HTTP/1.1 401 Unauthorized\r\n\r\n');
}
});
❌ Mistake 4: Forgetting TURN Servers for WebRTC
// BAD — STUN only, fails for ~15-20% of users
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
// GOOD — include TURN fallback
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: ['turn:turn.yourserver.com:3478', 'turns:turn.yourserver.com:5349'],
username: fetchedTurnUsername,
credential: fetchedTurnCredential,
},
],
iceTransportPolicy: 'all', // 'relay' to force TURN only (for debugging)
});
❌ Mistake 5: Memory Leaks from Unremoved Listeners
// BAD — event listeners accumulate on re-render
useEffect(() => {
ws.on('message', handleMessage);
// Missing cleanup!
});
// GOOD — always clean up
useEffect(() => {
ws.addEventListener('message', handleMessage);
return () => ws.removeEventListener('message', handleMessage);
}, [handleMessage]);
🚀 Pro Tips
-
Use
SharedWorkerfor WebSocket connections. If users open your app in multiple tabs, aSharedWorkerlets all tabs share one WebSocket connection — reducing server load and eliminating duplicate events. -
Implement optimistic UI updates. Don't wait for the server to echo back a change. Update local state immediately, then reconcile with the server response. This makes your app feel instant.
-
Leverage HTTP/2 Server-Sent Events (SSE) for one-way streams. For read-only dashboards (no client → server messages needed), SSE is simpler than WebSockets and works seamlessly through HTTP/2 multiplexing and standard CDN infrastructure.
-
Monitor WebSocket queue depth. If
ws.bufferedAmountgrows, your client can't keep up. Back off or drop non-critical messages. -
Use message schemas with versioning. Define your WebSocket message format with explicit
versionandtypefields from day one. Migrating live protocol formats without versioning is painful. -
Test with Chaos. Simulate network drops, high latency, and packet loss with tools like
tc(Linux traffic control) orNetwork Link Conditioneron macOS. Real-time apps reveal their weaknesses under degraded networks. -
For video/audio in WebRTC, prefer SFU over mesh. A full mesh works for 2–4 peers. Beyond that, use an SFU (Selective Forwarding Unit) like mediasoup, LiveKit, or Janus. The math is brutal: 8 peers in mesh = 56 connections. With SFU = 8 connections.
📌 Key Takeaways
-
WebSockets provide persistent, bidirectional, server-mediated channels. Use them for dashboards, notifications, collaborative features, and chat.
-
WebRTC enables direct peer-to-peer communication for video, audio, and data. Use it when you need low latency and want to avoid routing media through your server.
-
Signaling is required for WebRTC but carries no real-time data — it's just a bootstrap mechanism. Any WebSocket server works as a signaling server.
-
TURN servers are non-negotiable in production WebRTC — STUN alone fails too often in real-world network environments.
-
Scale WebSocket servers horizontally using Redis Pub/Sub or a message broker to fan out events across instances.
-
CRDTs (via libraries like Yjs) solve collaborative state conflicts elegantly — they're the right foundation for Google Docs-style features.
-
Security first: authenticate WebSocket connections at the HTTP upgrade, not after. For WebRTC, use ephemeral TURN credentials rotated per session.
-
Real-time UX is more than the protocol. Optimistic updates, smooth cursor interpolation, presence indicators, and graceful degradation on reconnect are what separate polished real-time apps from brittle ones.
Conclusion
Real-time web experiences are no longer the exclusive domain of companies with enormous engineering teams. WebSockets and WebRTC are mature, well-supported technologies with great tooling — and in 2026, users simply expect the web to be live.
Start with WebSockets for your dashboards and collaborative features. Reach for WebRTC when you need P2P video, ultra-low latency, or want to minimize server egress costs. Invest early in your reconnection logic, authentication strategy, and scalability story — these are the details that determine whether your real-time feature is a delight or a liability.
Build something live. The web feels different when it breathes.