Skip to content

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.

4 min read
Real-time dashboard showing live metrics with WebSocket connection status indicator

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.

tstypescript
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.

tstypescript
// 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);
}
tstypescript
// ❌ 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.

tstypescript
// 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.

tstypescript
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.

tstypescript
// 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

  1. Define a typed message protocol — shared TypeScript types between client and server prevent miscommunication and make the API self-documenting
  2. Use channel-based subscriptions — clients subscribe only to the data they need, reducing unnecessary bandwidth and processing
  3. Implement exponential backoff reconnection — connections will drop; automatic reconnection with increasing delays prevents server overload
  4. Add heartbeat monitoring — ping/pong cycles detect silently dead connections that would otherwise leak resources
  5. Re-subscribe after reconnection — the client must restore its subscription state when it reconnects, since the server assigns a new session
  6. Broadcast efficiently — iterate over clients and their subscriptions rather than sending all data to all clients
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX