Saltar al contenido

Cómo construir un dashboard en tiempo real con WebSockets

Tutorial paso a paso de un dashboard en vivo con WebSockets: configuración del servidor, gestión de conexiones, streaming, reconexión y escalado.

5 min de lectura
Dashboard en tiempo real mostrando métricas en vivo con un indicador de estado de la conexión WebSocket

El modelo HTTP de petición-respuesta funciona bien para páginas estáticas y envíos de formularios. Pero cuando necesitas datos que se actualizan en vivo — dashboards de monitorización, aplicaciones de chat, edición colaborativa, marcadores deportivos en directo — el polling introduce latencia innecesaria y desperdicia ancho de banda. Los WebSockets te dan una conexión persistente y full-duplex entre cliente y servidor, lo que permite al servidor enviar actualizaciones en el instante en que ocurren.

Este tutorial recorre la construcción de un dashboard de métricas en tiempo real desde cero usando WebSockets en TypeScript.

Configuración del servidor con ws

Usaremos la librería ws para el servidor WebSocket. Es ligera, probada en producción y funciona en Node.js sin dependencias adicionales.

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');
});

Protocolo de mensajes

Define un protocolo de mensajes tipado para que tanto el cliente como el servidor sepan qué esperar. Esto evita el caos de los mensajes de texto sin estructura.

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
  }
}

Difusión de datos a los suscriptores

El servidor recopila métricas y las difunde a los clientes suscritos. Solo los clientes suscritos a un canal específico reciben los datos de ese canal.

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();

Conexión del lado del cliente con reconexión

El cliente debe manejar con elegancia las caídas de conexión. Las redes no son fiables: la conexión WebSocket se cerrará en algún momento. La reconexión automática con backoff exponencial evita sobrecargar el servidor.

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 y limpieza de conexiones inactivas

Las conexiones WebSocket pueden morir silenciosamente sin que ninguna de las partes reciba un evento de cierre, especialmente detrás de balanceadores de carga o puertas de enlace NAT. Un mecanismo de heartbeat detecta y limpia las conexiones inactivas.

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();

Conclusiones clave

  1. Define un protocolo de mensajes tipado — los tipos TypeScript compartidos entre cliente y servidor evitan malentendidos y hacen que la API se autodocumente
  2. Usa suscripciones basadas en canales — los clientes se suscriben solo a los datos que necesitan, reduciendo ancho de banda y procesamiento innecesarios
  3. Implementa reconexión con backoff exponencial — las conexiones se caerán; la reconexión automática con retardos crecientes evita sobrecargar el servidor
  4. Añade monitorización con heartbeat — los ciclos de ping/pong detectan conexiones silenciosamente muertas que de otro modo desperdiciarían recursos
  5. Vuelve a suscribirte tras la reconexión — el cliente debe restaurar su estado de suscripción al reconectarse, ya que el servidor asigna una nueva sesión
  6. Difunde de forma eficiente — itera sobre los clientes y sus suscripciones en lugar de enviar todos los datos a todos los clientes
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX