Building a Real-Time Dashboard with WebSockets
A step-by-step tutorial for a live-updating dashboard with WebSockets: server setup, connection management, data streaming, reconnection and scaling.

HTTP request-response works fine for static pages and form submissions. But when you need live-updating data — monitoring dashboards, chat applications, collaborative editing, live sports scores — polling introduces unnecessary latency and wasted bandwidth. WebSockets give you a persistent, full-duplex connection between client and server, allowing the server to push updates the instant they happen.
This tutorial walks through building a real-time metrics dashboard from scratch using WebSockets in TypeScript.
Server Setup with ws
We will use the ws library for the WebSocket server. It is lightweight, production-tested, and runs on Node.js without additional dependencies.
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
// Create an HTTP server that the WebSocket server will attach to
const httpServer = createServer();
const wss = new WebSocketServer({ server: httpServer });
// Track connected clients with metadata
interface ClientConnection {
ws: WebSocket;
id: string;
subscribedChannels: Set<string>;
lastPing: number;
}
const clients: Map<string, ClientConnection> = new Map();
wss.on('connection', (ws: WebSocket) => {
const clientId = crypto.randomUUID();
const client: ClientConnection = {
ws,
id: clientId,
subscribedChannels: new Set(),
lastPing: Date.now(),
};
clients.set(clientId, client);
console.log(`Client connected: ${clientId} (total: ${clients.size})`);
// Send initial state on connection
ws.send(JSON.stringify({
type: 'connected',
clientId,
availableChannels: ['cpu', 'memory', 'requests', 'errors'],
}));
ws.on('message', (data: Buffer) => {
handleMessage(client, data.toString());
});
ws.on('close', () => {
clients.delete(clientId);
console.log(`Client disconnected: ${clientId} (total: ${clients.size})`);
});
ws.on('pong', () => {
client.lastPing = Date.now();
});
});
httpServer.listen(8080, () => {
console.log('WebSocket server running on ws://localhost:8080');
});Message Protocol
Define a typed message protocol so both client and server know what to expect. This prevents the chaos of unstructured string messages.
// Shared types between client and server
type ClientMessage =
| { type: 'subscribe'; channels: string[] }
| { type: 'unsubscribe'; channels: string[] }
| { type: 'ping' };
type ServerMessage =
| { type: 'connected'; clientId: string; availableChannels: string[] }
| { type: 'data'; channel: string; payload: MetricPayload; timestamp: number }
| { type: 'error'; message: string }
| { type: 'pong' };
interface MetricPayload {
value: number;
unit: string;
trend: 'up' | 'down' | 'stable';
history: number[]; // Last 60 data points
}
function handleMessage(client: ClientConnection, raw: string): void {
let message: ClientMessage;
try {
message = JSON.parse(raw) as ClientMessage;
} catch {
sendToClient(client, { type: 'error', message: 'Invalid JSON' });
return;
}
switch (message.type) {
case 'subscribe':
for (const channel of message.channels) {
if (isValidChannel(channel)) {
client.subscribedChannels.add(channel);
}
}
break;
case 'unsubscribe':
for (const channel of message.channels) {
client.subscribedChannels.delete(channel);
}
break;
case 'ping':
sendToClient(client, { type: 'pong' });
break;
}
}
function sendToClient(client: ClientConnection, message: ServerMessage): void {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.send(JSON.stringify(message));
}
}
const validChannels = new Set(['cpu', 'memory', 'requests', 'errors']);
function isValidChannel(channel: string): boolean {
return validChannels.has(channel);
}// ❌ Untyped, stringly-typed message handling
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.action === 'sub') { // Typos will silently fail
// Handle subscription
}
});
// ✅ Typed protocol with exhaustive message handling
function handleMessage(client: ClientConnection, raw: string): void {
const message = parseClientMessage(raw); // Validates structure
switch (message.type) {
case 'subscribe': handleSubscribe(client, message); break;
case 'unsubscribe': handleUnsubscribe(client, message); break;
case 'ping': sendToClient(client, { type: 'pong' }); break;
// TypeScript ensures all cases are handled
}
}Broadcasting Data to Subscribers
The server collects metrics and broadcasts them to subscribed clients. Only clients subscribed to a specific channel receive that channel's data.
// Simulate metric collection (replace with real data sources)
function collectMetrics(): Map<string, MetricPayload> {
const metrics = new Map<string, MetricPayload>();
metrics.set('cpu', {
value: 35 + Math.random() * 30,
unit: 'percent',
trend: Math.random() > 0.5 ? 'up' : 'stable',
history: Array.from({ length: 60 }, () => 30 + Math.random() * 40),
});
metrics.set('memory', {
value: 2048 + Math.random() * 1024,
unit: 'MB',
trend: 'up',
history: Array.from({ length: 60 }, () => 2000 + Math.random() * 1500),
});
metrics.set('requests', {
value: Math.floor(100 + Math.random() * 500),
unit: 'req/s',
trend: Math.random() > 0.7 ? 'up' : 'stable',
history: Array.from({ length: 60 }, () => Math.floor(50 + Math.random() * 600)),
});
metrics.set('errors', {
value: Math.floor(Math.random() * 10),
unit: 'errors/min',
trend: Math.random() > 0.8 ? 'up' : 'down',
history: Array.from({ length: 60 }, () => Math.floor(Math.random() * 15)),
});
return metrics;
}
// Broadcast metrics every second to subscribed clients
function startBroadcasting(): void {
setInterval(() => {
const metrics = collectMetrics();
const timestamp = Date.now();
for (const [clientId, client] of clients) {
for (const channel of client.subscribedChannels) {
const payload = metrics.get(channel);
if (payload) {
sendToClient(client, {
type: 'data',
channel,
payload,
timestamp,
});
}
}
}
}, 1000);
}
startBroadcasting();Client-Side Connection with Reconnection
The client needs to handle connection drops gracefully. Networks are unreliable — the WebSocket connection will close at some point. Automatic reconnection with exponential backoff prevents hammering the server.
class DashboardSocket {
private ws: WebSocket | null = null;
private url: string;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private listeners: Map<string, ((data: MetricPayload) => void)[]> = new Map();
private subscribedChannels: Set<string> = new Set();
constructor(url: string) {
this.url = url;
this.connect();
}
private connect(): void {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected to dashboard server');
this.reconnectAttempts = 0;
// Re-subscribe to channels after reconnection
if (this.subscribedChannels.size > 0) {
this.send({
type: 'subscribe',
channels: [...this.subscribedChannels],
});
}
};
this.ws.onmessage = (event: MessageEvent) => {
const message: ServerMessage = JSON.parse(event.data);
if (message.type === 'data') {
const handlers = this.listeners.get(message.channel) ?? [];
for (const handler of handlers) {
handler(message.payload);
}
}
};
this.ws.onclose = () => {
console.log('Connection closed');
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnect attempts reached');
return;
}
// Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
30_000
);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
subscribe(channel: string, callback: (data: MetricPayload) => void): void {
this.subscribedChannels.add(channel);
if (!this.listeners.has(channel)) {
this.listeners.set(channel, []);
}
this.listeners.get(channel)!.push(callback);
this.send({ type: 'subscribe', channels: [channel] });
}
private send(message: ClientMessage): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
disconnect(): void {
this.maxReconnectAttempts = 0; // Prevent reconnection
this.ws?.close();
}
}
// Usage
const dashboard = new DashboardSocket('ws://localhost:8080');
dashboard.subscribe('cpu', (data) => {
document.getElementById('cpu-value')!.textContent =
`${data.value.toFixed(1)}${data.unit}`;
});
dashboard.subscribe('errors', (data) => {
const el = document.getElementById('error-count')!;
el.textContent = `${data.value} ${data.unit}`;
el.className = data.value > 5 ? 'alert' : 'normal';
});Heartbeat and Stale Connection Cleanup
WebSocket connections can silently die without either side receiving a close event — especially behind load balancers or NAT gateways. A heartbeat mechanism detects and cleans up stale connections.
// Server-side heartbeat: ping clients every 30 seconds
function startHeartbeat(): void {
setInterval(() => {
const now = Date.now();
const staleThreshold = 60_000; // 60 seconds without a pong
for (const [clientId, client] of clients) {
if (now - client.lastPing > staleThreshold) {
console.log(`Terminating stale connection: ${clientId}`);
client.ws.terminate();
clients.delete(clientId);
continue;
}
// Send ping — client should respond with pong
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.ping();
}
}
}, 30_000);
}
startHeartbeat();Key Takeaways
- Define a typed message protocol — shared TypeScript types between client and server prevent miscommunication and make the API self-documenting
- Use channel-based subscriptions — clients subscribe only to the data they need, reducing unnecessary bandwidth and processing
- Implement exponential backoff reconnection — connections will drop; automatic reconnection with increasing delays prevents server overload
- Add heartbeat monitoring — ping/pong cycles detect silently dead connections that would otherwise leak resources
- Re-subscribe after reconnection — the client must restore its subscription state when it reconnects, since the server assigns a new session
- Broadcast efficiently — iterate over clients and their subscriptions rather than sending all data to all clients


