Saltar al contenido

Un dashboard en tiempo real con Server-Sent Events y React

Tutorial paso a paso de un dashboard en vivo con Server-Sent Events: el protocolo SSE, el servidor en Node.js y un cliente React con reconexión.

6 min de lectura
Dashboard en vivo con gráficos de streaming y actualizaciones de datos en tiempo real

Por qué Server-Sent Events y no WebSockets

Toda conversación sobre funcionalidades en tiempo real empieza por defecto con WebSockets. Pero para dashboards, notificaciones y feeds en vivo —casos en los que los datos fluyen principalmente del servidor al cliente— Server-Sent Events (SSE) son más simples, más fiables y requieren menos infraestructura.

SSE funciona sobre HTTP estándar. Sin actualización de protocolo, sin configuración especial del balanceador de carga, sin sesiones pegajosas. Se reconecta automáticamente cuando la conexión se cae. Funciona a través de proxies y CDNs que bloquean las actualizaciones de WebSocket. Para streaming de datos unidireccional, SSE es la opción pragmática.

Este tutorial construye un dashboard de métricas en tiempo real desde cero: un servidor Node.js que empuja eventos, un cliente React que los consume y los detalles de producción (reconexión, manejo de errores, backpressure) que los tutoriales suelen omitir.

El protocolo SSE en cinco minutos

SSE usa un protocolo simple basado en texto. El servidor envía una respuesta con Content-Type: text/event-stream y escribe los eventos como líneas de texto plano. Cada evento tiene campos opcionales: event (tipo), data (carga útil), id (para la reconexión) y retry (intervalo de reconexión).

plaintextplaintext
event: metric
id: 1001
data: {"name":"cpu_usage","value":72.5,"timestamp":"2024-10-05T14:30:00Z"}
 
event: metric
id: 1002
data: {"name":"memory_usage","value":68.3,"timestamp":"2024-10-05T14:30:01Z"}
 
event: alert
id: 1003
data: {"severity":"warning","message":"CPU usage above 70%"}

Los eventos se separan con saltos de línea dobles. El campo id habilita la reconexión automática: cuando el cliente se reconecta, envía el último ID recibido en la cabecera Last-Event-ID, y el servidor puede reenviar los eventos perdidos.

Implementación del servidor: streaming de eventos desde Node.js

El servidor mantiene la conexión HTTP abierta y escribe los eventos a medida que ocurren. El detalle clave de la implementación es la limpieza adecuada cuando los clientes se desconectan.

tstypescript
import { createServer, IncomingMessage, ServerResponse } from "http";
 
interface SSEClient {
  id: string;
  response: ServerResponse;
  lastEventId: number;
}
 
const clients: Map<string, SSEClient> = new Map();
let eventCounter = 0;
 
function setupSSEConnection(req: IncomingMessage, res: ServerResponse): void {
  const clientId = crypto.randomUUID();
 
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
    "X-Accel-Buffering": "no", // Disable nginx buffering
  });
 
  // Send initial retry interval
  res.write("retry: 5000\n\n");
 
  // Handle reconnection
  const lastEventId = parseInt(
    req.headers["last-event-id"] as string || "0",
    10
  );
 
  const client: SSEClient = {
    id: clientId,
    response: res,
    lastEventId,
  };
 
  clients.set(clientId, client);
 
  // Replay missed events if reconnecting
  if (lastEventId > 0) {
    replayEvents(client, lastEventId);
  }
 
  // Cleanup on disconnect
  req.on("close", () => {
    clients.delete(clientId);
    console.log(`Client ${clientId} disconnected. Active: ${clients.size}`);
  });
 
  console.log(`Client ${clientId} connected. Active: ${clients.size}`);
}
 
function sendEvent(
  client: SSEClient,
  eventType: string,
  data: object
): boolean {
  try {
    eventCounter++;
    const payload = [
      `event: ${eventType}`,
      `id: ${eventCounter}`,
      `data: ${JSON.stringify(data)}`,
      "",
      "",
    ].join("\n");
 
    return client.response.write(payload);
  } catch {
    clients.delete(client.id);
    return false;
  }
}
 
function broadcast(eventType: string, data: object): void {
  for (const [id, client] of clients) {
    const success = sendEvent(client, eventType, data);
    if (!success) {
      clients.delete(id);
    }
  }
}

La cabecera X-Accel-Buffering: no es crítica en despliegues con nginx. Sin ella, nginx almacena la respuesta en un búfer y los clientes reciben los eventos por lotes en lugar de en tiempo real.

Reenvío de eventos para una entrega fiable

Cuando un cliente se reconecta después de una interrupción de red, envía el último ID de evento que recibió. El servidor debería reenviar cualquier evento que el cliente se haya perdido. Un búfer de eventos acotado hace esto posible sin un crecimiento ilimitado de la memoria.

tstypescript
interface StoredEvent {
  id: number;
  type: string;
  data: object;
  timestamp: number;
}
 
const EVENT_BUFFER_SIZE = 1000;
const eventBuffer: StoredEvent[] = [];
 
function storeEvent(type: string, data: object): number {
  eventCounter++;
 
  const event: StoredEvent = {
    id: eventCounter,
    type,
    data,
    timestamp: Date.now(),
  };
 
  eventBuffer.push(event);
 
  // Keep buffer bounded
  if (eventBuffer.length > EVENT_BUFFER_SIZE) {
    eventBuffer.splice(0, eventBuffer.length - EVENT_BUFFER_SIZE);
  }
 
  return eventCounter;
}
 
function replayEvents(client: SSEClient, afterId: number): void {
  const missed = eventBuffer.filter((e) => e.id > afterId);
 
  for (const event of missed) {
    sendEvent(client, event.type, event.data);
  }
 
  if (missed.length > 0) {
    console.log(`Replayed ${missed.length} events for client ${client.id}`);
  }
}
 
// Modified broadcast that stores events
function broadcastAndStore(eventType: string, data: object): void {
  storeEvent(eventType, data);
  broadcast(eventType, data);
}

Un búfer de 1000 eventos es un compromiso entre el uso de memoria y la fiabilidad de la reconexión. Para un dashboard que empuja un evento por segundo, esto cubre unos 16 minutos de desconexión. Ajústalo según los patrones de desconexión que esperes.

Cliente React: consumir SSE con hooks

La API EventSource integrada en el navegador maneja las conexiones SSE, la reconexión automática y el análisis de eventos. Envolverla en un hook de React te da una integración limpia con el ciclo de vida de los componentes.

tstypescript
// ❌ Bad: Bare EventSource without cleanup or error handling
import { useEffect, useState } from "react";
 
function useBadSSE(url: string) {
  const [data, setData] = useState(null);
 
  useEffect(() => {
    const source = new EventSource(url);
    source.onmessage = (e) => setData(JSON.parse(e.data));
    // No cleanup! Connection leaks on unmount
    // No error handling! Silent failures
  }, [url]);
 
  return data;
}
tstypescript
// ✅ Good: Full SSE hook with typed events, reconnection, and cleanup
import { useEffect, useRef, useState, useCallback } from "react";
 
interface SSEOptions {
  onOpen?: () => void;
  onError?: (error: Event) => void;
  maxRetries?: number;
}
 
interface SSEState<T> {
  data: T | null;
  isConnected: boolean;
  error: string | null;
  retryCount: number;
}
 
function useSSE<T>(
  url: string,
  eventType: string,
  options: SSEOptions = {}
): SSEState<T> {
  const [state, setState] = useState<SSEState<T>>({
    data: null,
    isConnected: false,
    error: null,
    retryCount: 0,
  });
 
  const sourceRef = useRef<EventSource | null>(null);
  const retryCountRef = useRef(0);
  const maxRetries = options.maxRetries ?? 10;
 
  const connect = useCallback(() => {
    if (sourceRef.current) {
      sourceRef.current.close();
    }
 
    const source = new EventSource(url);
    sourceRef.current = source;
 
    source.addEventListener("open", () => {
      retryCountRef.current = 0;
      setState((prev) => ({
        ...prev,
        isConnected: true,
        error: null,
        retryCount: 0,
      }));
      options.onOpen?.();
    });
 
    source.addEventListener(eventType, (event: MessageEvent) => {
      try {
        const parsed = JSON.parse(event.data) as T;
        setState((prev) => ({ ...prev, data: parsed }));
      } catch {
        console.error("Failed to parse SSE data:", event.data);
      }
    });
 
    source.addEventListener("error", (event: Event) => {
      setState((prev) => ({
        ...prev,
        isConnected: false,
        retryCount: retryCountRef.current,
      }));
 
      if (retryCountRef.current >= maxRetries) {
        source.close();
        setState((prev) => ({
          ...prev,
          error: "Max reconnection attempts reached",
        }));
      }
 
      retryCountRef.current++;
      options.onError?.(event);
    });
 
    return source;
  }, [url, eventType, maxRetries, options]);
 
  useEffect(() => {
    const source = connect();
 
    return () => {
      source.close();
      sourceRef.current = null;
    };
  }, [connect]);
 
  return state;
}

El hook rastrea el estado de la conexión, el contador de reintentos y los errores: todo lo que la UI necesita para mostrar indicadores de estado de la conexión. La API EventSource maneja la reconexión automáticamente, pero el contador de reintentos te permite implementar un backoff máximo.

Componente del dashboard: ensamblando las piezas

Con el hook de SSE en su lugar, construir el dashboard es una composición directa de componentes React.

tsxtsx
import { useState, useEffect } from "react";
 
interface MetricData {
  name: string;
  value: number;
  timestamp: string;
}
 
interface AlertData {
  severity: "info" | "warning" | "critical";
  message: string;
}
 
function MetricsDashboard() {
  const metrics = useSSE<MetricData>("/api/events", "metric");
  const alerts = useSSE<AlertData>("/api/events", "alert");
  const [history, setHistory] = useState<MetricData[]>([]);
 
  useEffect(() => {
    if (metrics.data) {
      setHistory((prev) => {
        const updated = [...prev, metrics.data!];
        return updated.slice(-100); // Keep last 100 data points
      });
    }
  }, [metrics.data]);
 
  return (
    <div className="grid grid-cols-1 gap-6 p-6 md:grid-cols-2">
      <ConnectionStatus isConnected={metrics.isConnected} />
 
      <MetricCard
        label="CPU Usage"
        value={metrics.data?.value ?? 0}
        unit="%"
        threshold={80}
      />
 
      <MetricHistory dataPoints={history} />
 
      {alerts.data && (
        <AlertBanner
          severity={alerts.data.severity}
          message={alerts.data.message}
        />
      )}
    </div>
  );
}
 
function ConnectionStatus({ isConnected }: { isConnected: boolean }) {
  return (
    <div className="flex items-center gap-2 text-sm">
      <span
        className={`h-2 w-2 rounded-full ${
          isConnected ? "bg-green-500" : "bg-red-500"
        }`}
      />
      {isConnected ? "Live" : "Reconnecting..."}
    </div>
  );
}
 
function MetricCard({
  label,
  value,
  unit,
  threshold,
}: {
  label: string;
  value: number;
  unit: string;
  threshold: number;
}) {
  const isWarning = value > threshold;
 
  return (
    <div
      className={`rounded-lg border p-4 ${
        isWarning ? "border-red-300 bg-red-50" : "border-gray-200"
      }`}
    >
      <p className="text-sm text-gray-500">{label}</p>
      <p className={`text-3xl font-bold ${isWarning ? "text-red-600" : ""}`}>
        {value.toFixed(1)}
        {unit}
      </p>
    </div>
  );
}

El búfer de historial limitado a 100 puntos de datos evita el crecimiento de la memoria en sesiones de larga duración. Para dashboards en producción, considera usar un búfer circular o descargar los puntos de datos antiguos a IndexedDB.

Detalles de producción: escalar SSE

Las conexiones SSE son conexiones HTTP de larga duración. Cada cliente conectado mantiene una conexión abierta en tu servidor. Esto tiene implicaciones para el escalado.

tstypescript
// Server-side connection limits and backpressure
const MAX_CLIENTS = 10000;
 
function setupSSEConnection(req: IncomingMessage, res: ServerResponse): void {
  if (clients.size >= MAX_CLIENTS) {
    res.writeHead(503, { "Retry-After": "30" });
    res.end("Server at capacity");
    return;
  }
 
  // Keep-alive to prevent proxy timeouts
  const keepAliveInterval = setInterval(() => {
    try {
      res.write(":keepalive\n\n");
    } catch {
      clearInterval(keepAliveInterval);
    }
  }, 15000);
 
  req.on("close", () => {
    clearInterval(keepAliveInterval);
    clients.delete(clientId);
  });
 
  // ... rest of setup
}

El comentario keepalive (:keepalive) es un comentario SSE válido que evita que los proxies y balanceadores de carga cierren las conexiones inactivas. Envía uno cada 15-30 segundos. Sin esto, AWS ALB cierra las conexiones inactivas tras 60 segundos, lo que desencadena ciclos de reconexión innecesarios.

Conclusiones clave

Server-Sent Events son la herramienta adecuada para el streaming de servidor a cliente: dashboards, notificaciones, feeds en vivo, indicadores de progreso. Son más simples de implementar que WebSockets, funcionan a través de la infraestructura HTTP y manejan la reconexión de forma nativa.

Los detalles de producción importan: almacena eventos en un búfer para reenviarlos tras una reconexión, envía comentarios keepalive para evitar timeouts de proxy, limita las conexiones de clientes con backpressure y libera los recursos al desconectar. En el cliente React, rastrea el estado de la conexión y expónlo a la UI para que los usuarios sepan cuándo los datos están desactualizados.

SSE tiene limitaciones reales: no hay mensajería de cliente a servidor (usa peticiones POST normales), un límite de navegador de 6 conexiones por dominio (HTTP/2 lo eleva a 100) y no admite datos binarios. Para la comunicación bidireccional, WebSockets sigue siendo la opción correcta. Pero para el caso común de empujar actualizaciones del servidor al cliente, SSE hace el trabajo con menos complejidad.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX