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.

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.
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.
// 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
}
}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.
// 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.
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.
// 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
- Define un protocolo de mensajes tipado — los tipos TypeScript compartidos entre cliente y servidor evitan malentendidos y hacen que la API se autodocumente
- Usa suscripciones basadas en canales — los clientes se suscriben solo a los datos que necesitan, reduciendo ancho de banda y procesamiento innecesarios
- Implementa reconexión con backoff exponencial — las conexiones se caerán; la reconexión automática con retardos crecientes evita sobrecargar el servidor
- Añade monitorización con heartbeat — los ciclos de ping/pong detectan conexiones silenciosamente muertas que de otro modo desperdiciarían recursos
- 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
- Difunde de forma eficiente — itera sobre los clientes y sus suscripciones en lugar de enviar todos los datos a todos los clientes


