Skip to main content
backend26 de octubre de 202515 min de lectura

Datos en Tiempo Real: WebSockets, SSE, y Cuándo Realmente los Necesitas

Entendiendo los patrones de datos en tiempo real — desde WebSockets hasta Server-Sent Events, Firebase Realtime Database, y saber cuándo el polling es suficiente.

websocketsrealtimesse
Datos en Tiempo Real: WebSockets, SSE, y Cuándo Realmente los Necesitas

La mayoría de las aplicaciones que dicen necesitar datos en tiempo real no necesitan realmente datos en tiempo real. Necesitan datos que se sientan actuales — actualizados en unos pocos segundos, no en unos pocos milisegundos. La distinción importa porque las arquitecturas verdaderamente en tiempo real introducen complejidad en la gestión de conexiones, sincronización de estado, escalamiento y manejo de errores que son completamente innecesarios si tus usuarios estarían perfectamente contentos con un retraso de cinco segundos.

He construido funcionalidades genuinamente en tiempo real — seguimiento de pedidos en vivo para una plataforma de restaurantes, interfaces de edición colaborativa, dashboards en vivo — y también he construido funcionalidades donde inicialmente sobre-ingenierié con WebSockets y luego las reemplacé con polling que refresca cada diez segundos. Nadie notó el cambio. Comenzar con el enfoque más simple y agregar complejidad solo cuando los usuarios realmente experimentan un problema no es solo pragmático — es la elección de ingeniería responsable.

Cuándo Realmente Necesitas Tiempo Real

El tiempo real verdadero está justificado cuando el valor de la información se degrada rápidamente con la latencia.

Chat y mensajería. Los usuarios esperan que los mensajes aparezcan en menos de un segundo. Un retraso de cinco segundos hace que una conversación se sienta rota. El tiempo real no es opcional aquí.

Edición colaborativa. La edición simultánea al estilo Google Docs requiere sincronización por debajo del segundo para evitar ediciones conflictivas. La complejidad aquí va más allá del transporte — necesitas transformación operacional o CRDTs para la resolución de conflictos.

Dashboards en vivo con impacto operacional. Un dashboard de monitoreo donde un ingeniero está observando anomalías durante un despliegue necesita actualizaciones en tiempo real. Un dashboard de analytics de negocio que se ve una vez al día no.

Juegos y experiencias interactivas. Juegos multijugador, subastas en vivo, encuestas en tiempo real — cualquier cosa donde múltiples usuarios interactúan con estado compartido simultáneamente.

Trading financiero. Feeds de precios, actualizaciones de libro de órdenes, cambios de posición. Los milisegundos importan aquí, y la arquitectura lo refleja.

Cuándo No Necesitas Tiempo Real

Feeds de redes sociales. Twitter e Instagram se sienten en tiempo real, pero usan una combinación de polling al hacer focus y notificaciones push. El feed no se actualiza mientras lo estás mirando — tiras para refrescar.

Inventario de e-commerce. "Solo quedan 3!" no necesita actualizarse en tiempo real. Verificar en la carga de página y al checkout es suficiente.

Conteos de notificaciones. La insignia roja mostrando "5 nuevas notificaciones" se puede consultar por polling cada 30 segundos. Los usuarios no notan si una notificación aparece 30 segundos después de ser creada.

Actualizaciones de contenido. Posts de blog, listados de productos, perfiles de usuario — cualquier dato que cambia infrecuentemente. Haz polling en la carga de página o usa headers de caché HTTP con revalidación.

Polling: El Valor Predeterminado Subestimado

Polling — hacer solicitudes HTTP periódicas para verificar nuevos datos — es el enfoque más simple y funciona para más casos de uso de lo que la mayoría de los desarrolladores asume.

Polling Simple

function usePolledData<T>(url: string, intervalMs: number = 5000) {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let active = true;

    const fetchData = async () => {
      try {
        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const result = await response.json();
        if (active) setData(result);
      } catch (e) {
        if (active) setError(e as Error);
      }
    };

    fetchData(); // Initial fetch
    const interval = setInterval(fetchData, intervalMs);

    return () => {
      active = false;
      clearInterval(interval);
    };
  }, [url, intervalMs]);

  return { data, error };
}

Polling Inteligente con Solicitudes Condicionales

Las solicitudes condicionales HTTP (If-None-Match con ETags o If-Modified-Since) te permiten hacer polling eficientemente. El servidor devuelve 304 Not Modified sin cuerpo si los datos no han cambiado, reduciendo el ancho de banda a casi cero para recursos sin cambios.

async function pollWithETag(url: string): Promise<{ data: any; changed: boolean }> {
  const cachedETag = etagCache.get(url);

  const headers: HeadersInit = {};
  if (cachedETag) {
    headers['If-None-Match'] = cachedETag;
  }

  const response = await fetch(url, { headers });

  if (response.status === 304) {
    return { data: dataCache.get(url), changed: false };
  }

  const etag = response.headers.get('ETag');
  if (etag) etagCache.set(url, etag);

  const data = await response.json();
  dataCache.set(url, data);

  return { data, changed: true };
}

Cuándo el Polling Deja de Funcionar

El polling deja de ser viable cuando:

  • Necesitas latencia por debajo del segundo. Hacer polling cada 500ms es esencialmente un ataque DoS contra tu propia API.
  • Los datos cambian raramente pero necesitan entregarse instantáneamente. Hacer polling cada 5 segundos para un evento que ocurre una vez por hora desperdicia 719 solicitudes por hora.
  • Tienes miles de clientes. Cada cliente de polling es una conexión independiente. A escala, la sobrecarga HTTP de establecer conexiones, parsear headers y autenticar se acumula.

Server-Sent Events: El Tiempo Real Más Simple

Server-Sent Events (SSE) son la tecnología de tiempo real más subutilizada en el desarrollo web. SSE proporciona un flujo unidireccional de servidor a cliente sobre una conexión HTTP estándar. El navegador maneja la reconexión automáticamente, y el protocolo es trivialmente simple.

Implementación del Servidor

// Express.js SSE endpoint
app.get('/events/orders/:restaurantId', (req, res) => {
  const { restaurantId } = req.params;

  // SSE headers
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no', // Disable nginx buffering
  });

  // Send initial data
  const initialOrders = getActiveOrders(restaurantId);
  res.write(`data: ${JSON.stringify(initialOrders)}\n\n`);

  // Subscribe to order changes
  const unsubscribe = orderEmitter.on(`orders:${restaurantId}`, (order) => {
    res.write(`event: orderUpdate\n`);
    res.write(`data: ${JSON.stringify(order)}\n`);
    res.write(`id: ${order.id}-${order.updatedAt}\n\n`);
  });

  // Heartbeat to detect dead connections
  const heartbeat = setInterval(() => {
    res.write(`: heartbeat\n\n`);
  }, 30000);

  // Cleanup on disconnect
  req.on('close', () => {
    clearInterval(heartbeat);
    unsubscribe();
  });
});

Implementación del Cliente

function useSSE(url: string) {
  const [data, setData] = useState(null);
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    const eventSource = new EventSource(url);

    eventSource.onopen = () => setConnected(true);

    eventSource.onmessage = (event) => {
      setData(JSON.parse(event.data));
    };

    eventSource.addEventListener('orderUpdate', (event) => {
      const order = JSON.parse(event.data);
      setData(prev => updateOrderInList(prev, order));
    });

    eventSource.onerror = () => {
      setConnected(false);
      // EventSource automatically reconnects
    };

    return () => eventSource.close();
  }, [url]);

  return { data, connected };
}

Por Qué SSE Sobre WebSockets

Reconexión automática. La implementación de EventSource del navegador maneja la reconexión con backoff exponencial automáticamente. Con WebSockets, implementas esto tú mismo.

Funciona a través de proxies y load balancers. SSE usa HTTP estándar, así que funciona a través de nginx, CloudFlare y la mayoría de los reverse proxies sin configuración especial. WebSockets requiere soporte explícito de proxy.

Autenticación más simple. Las conexiones SSE llevan cookies y pueden usar headers de autenticación HTTP estándar. La autenticación de WebSocket requiere enviar credenciales después de que la conexión se establece, lo cual es un protocolo adicional a implementar.

Tipos de eventos e IDs integrados. El protocolo SSE soporta eventos nombrados e IDs de mensaje, lo que permite al cliente reanudar desde donde lo dejó después de una reconexión.

Limitaciones de SSE

  • Solo unidireccional. De servidor a cliente. Si necesitas comunicación bidireccional, SSE no puede hacerlo. Puedes combinar SSE con solicitudes HTTP POST regulares para mensajes de cliente a servidor, lo cual funciona bien para muchos casos de uso.
  • Límites de conexión del navegador. Los navegadores limitan el número de conexiones HTTP simultáneas a un solo dominio (típicamente seis). Cada conexión SSE cuenta contra este límite. El multiplexing de HTTP/2 mitiga esto, pero vale la pena tenerlo en cuenta.
  • Sin datos binarios. SSE es solo texto. Si necesitas transmitir datos binarios (audio, video, archivos), usa WebSockets o un mecanismo separado.

WebSockets: Comunicación Full Duplex

WebSockets proporcionan comunicación full duplex — tanto el cliente como el servidor pueden enviar mensajes en cualquier momento sin la sobrecarga de ciclos de solicitud/respuesta HTTP. Esto es necesario cuando necesitas comunicación bidireccional de baja latencia.

Implementación del Servidor con ws

import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';

const server = createServer();
const wss = new WebSocketServer({ server });

// Connection management
const rooms = new Map<string, Set<WebSocket>>();

wss.on('connection', (ws, req) => {
  // Authenticate the connection
  const token = new URL(req.url!, `http://${req.headers.host}`).searchParams.get('token');
  const user = verifyToken(token);

  if (!user) {
    ws.close(4001, 'Unauthorized');
    return;
  }

  // Attach user data
  (ws as any).userId = user.id;

  ws.on('message', (raw) => {
    try {
      const message = JSON.parse(raw.toString());
      handleMessage(ws, user, message);
    } catch (e) {
      ws.send(JSON.stringify({ error: 'Invalid message format' }));
    }
  });

  ws.on('close', () => {
    // Remove from all rooms
    rooms.forEach((clients, roomId) => {
      clients.delete(ws);
      if (clients.size === 0) rooms.delete(roomId);
    });
  });

  // Heartbeat
  (ws as any).isAlive = true;
  ws.on('pong', () => { (ws as any).isAlive = true; });
});

// Dead connection detection
const heartbeatInterval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!(ws as any).isAlive) {
      ws.terminate();
      return;
    }
    (ws as any).isAlive = false;
    ws.ping();
  });
}, 30000);

function handleMessage(ws: WebSocket, user: any, message: any) {
  switch (message.type) {
    case 'join_room':
      joinRoom(ws, message.roomId);
      break;
    case 'leave_room':
      leaveRoom(ws, message.roomId);
      break;
    case 'broadcast':
      broadcastToRoom(message.roomId, {
        type: 'message',
        userId: user.id,
        content: message.content,
        timestamp: Date.now(),
      }, ws);
      break;
  }
}

function joinRoom(ws: WebSocket, roomId: string) {
  if (!rooms.has(roomId)) rooms.set(roomId, new Set());
  rooms.get(roomId)!.add(ws);
}

function broadcastToRoom(roomId: string, data: any, exclude?: WebSocket) {
  const clients = rooms.get(roomId);
  if (!clients) return;

  const payload = JSON.stringify(data);
  clients.forEach((client) => {
    if (client !== exclude && client.readyState === WebSocket.OPEN) {
      client.send(payload);
    }
  });
}

server.listen(8080);

Implementación del Cliente con Reconexión

class WebSocketClient {
  private ws: WebSocket | null = null;
  private url: string;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 10;
  private handlers = new Map<string, Set<(data: any) => void>>();

  constructor(url: string) {
    this.url = url;
  }

  connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      this.reconnectAttempts = 0;
      this.emit('connected', null);
    };

    this.ws.onmessage = (event) => {
      try {
        const message = JSON.parse(event.data);
        this.emit(message.type, message);
      } catch (e) {
        console.error('Failed to parse WebSocket message:', e);
      }
    };

    this.ws.onclose = (event) => {
      this.emit('disconnected', { code: event.code, reason: event.reason });

      if (event.code !== 4001 && this.reconnectAttempts < this.maxReconnectAttempts) {
        this.scheduleReconnect();
      }
    };

    this.ws.onerror = () => {
      // Error is followed by close event, handle reconnection there
    };
  }

  private scheduleReconnect() {
    const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
    this.reconnectAttempts++;

    setTimeout(() => {
      this.emit('reconnecting', { attempt: this.reconnectAttempts });
      this.connect();
    }, delay);
  }

  send(type: string, data: any) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type, ...data }));
    }
  }

  on(event: string, handler: (data: any) => void) {
    if (!this.handlers.has(event)) this.handlers.set(event, new Set());
    this.handlers.get(event)!.add(handler);
    return () => this.handlers.get(event)?.delete(handler);
  }

  private emit(event: string, data: any) {
    this.handlers.get(event)?.forEach((handler) => handler(data));
  }

  disconnect() {
    this.maxReconnectAttempts = 0; // Prevent reconnection
    this.ws?.close();
  }
}

Estrategia de Reconexión

La lógica de reconexión merece atención porque afecta directamente la experiencia del usuario y la carga del servidor.

El backoff exponencial previene problemas de thundering herd. Si tu servidor se reinicia y 10,000 clientes se reconectan simultáneamente, el servidor se cae de nuevo. Agregando jitter y retrasos exponenciales, las conexiones se distribuyen en el tiempo:

function getReconnectDelay(attempt: number): number {
  const baseDelay = 1000;
  const maxDelay = 30000;
  const exponentialDelay = baseDelay * Math.pow(2, attempt);
  const jitter = Math.random() * 1000; // Random jitter up to 1 second
  return Math.min(exponentialDelay + jitter, maxDelay);
}

La UI de estado de conexión importa. Los usuarios necesitan saber cuándo están desconectados y cuándo la app está intentando reconectarse. Un pequeño banner diciendo "Reconectando..." es mejor que mostrar silenciosamente datos obsoletos.

Socket.IO: Conveniencia vs Control

Socket.IO es la librería de WebSocket más popular, y proporciona conveniencia significativa: reconexión automática, gestión de salas, acknowledgments y fallback a polling cuando WebSockets no está disponible. También agrega sobrecarga significativa e introduce una capa de abstracción que puede enmascarar problemas.

Cuándo Usar Socket.IO

  • Prototipado rápido. La API de Socket.IO es más simple que WebSockets crudos, y las funcionalidades integradas ahorran tiempo de desarrollo.
  • Cuando necesitas fallback a polling. Algunas redes corporativas bloquean conexiones WebSocket. Socket.IO automáticamente recurre a HTTP long polling.
  • Gestión de salas y namespaces. Si tu funcionalidad en tiempo real mapea naturalmente a salas (salas de chat, lobbies de juegos, documentos colaborativos), las abstracciones de salas de Socket.IO ahorran boilerplate.

Cuándo Evitar Socket.IO

  • Aplicaciones de alto rendimiento. El protocolo de Socket.IO agrega sobrecarga a cada mensaje (framing, encoding, metadata). Para aplicaciones de alto throughput, WebSockets crudos son mediblemente más rápidos.
  • Cuando necesitas interoperabilidad. Socket.IO no es WebSocket estándar — un cliente WebSocket regular no puede conectarse a un servidor Socket.IO. Si tus clientes incluyen entornos no-JavaScript (apps móviles, dispositivos IoT), Socket.IO requiere una librería cliente para cada plataforma.
  • Cuando quieres entender qué está pasando. Socket.IO abstrae la gestión de conexiones, lo cual es conveniente hasta que algo sale mal. Depurar un problema de conexión de Socket.IO frecuentemente significa entender la capa de abstracción encima del problema real.

Firebase Realtime Database y Firestore

Firebase proporciona capacidades en tiempo real sin gestionar infraestructura de WebSocket. Esta es su propuesta de valor principal — intercambias control por conveniencia.

Firebase Realtime Database

import { getDatabase, ref, onValue, set } from 'firebase/database';

const db = getDatabase();

// Listen for real-time updates
const ordersRef = ref(db, `restaurants/${restaurantId}/orders`);
onValue(ordersRef, (snapshot) => {
  const orders = snapshot.val();
  updateUI(orders);
});

// Write data (triggers listeners on all connected clients)
await set(ref(db, `restaurants/${restaurantId}/orders/${orderId}`), {
  status: 'preparing',
  updatedAt: Date.now(),
});

La Realtime Database es un árbol JSON. Cada escritura a cualquier nodo se propaga inmediatamente a cada cliente escuchando en ese nodo o cualquier nodo padre. Esto es poderoso pero peligroso — un listener en un nodo de alto nivel recibe actualizaciones para cada cambio en todo el subárbol.

Listeners en Tiempo Real de Firestore

import { getFirestore, collection, onSnapshot, query, where } from 'firebase/firestore';

const db = getFirestore();

// Listen for active orders
const q = query(
  collection(db, 'orders'),
  where('restaurantId', '==', restaurantId),
  where('status', 'in', ['pending', 'preparing', 'ready'])
);

const unsubscribe = onSnapshot(q, (snapshot) => {
  snapshot.docChanges().forEach((change) => {
    if (change.type === 'added') addOrder(change.doc.data());
    if (change.type === 'modified') updateOrder(change.doc.data());
    if (change.type === 'removed') removeOrder(change.doc.id);
  });
});

Los listeners en tiempo real de Firestore son más granulares que la Realtime Database — puedes escuchar consultas específicas, no solo rutas. El método docChanges() te dice exactamente qué cambió, lo cual hace posible actualizaciones eficientes de la UI.

Supabase Realtime

Supabase ofrece capacidades en tiempo real sobre PostgreSQL, lo que te da el poder de consulta de SQL con actualizaciones en tiempo real. Usa las funcionalidades de replicación de PostgreSQL (replicación lógica y el WAL) para detectar cambios y transmitirlos a través de canales WebSocket.

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(url, key);

// Listen for changes to the orders table
const channel = supabase
  .channel('orders')
  .on(
    'postgres_changes',
    {
      event: '*',
      schema: 'public',
      table: 'orders',
      filter: `restaurant_id=eq.${restaurantId}`,
    },
    (payload) => {
      console.log('Change type:', payload.eventType);
      console.log('New data:', payload.new);
      console.log('Old data:', payload.old);
    }
  )
  .subscribe();

La ventaja sobre Firebase es clara: tus datos viven en PostgreSQL con capacidades completas de consulta SQL, transacciones ACID y herramientas estándar. La capa de tiempo real es una adición, no un reemplazo de tu motor de consultas. La compensación es que Supabase Realtime está menos probado en batalla a escala que Firebase, y el rendimiento en tiempo real depende del throughput de replicación de PostgreSQL.

Escalando Conexiones WebSocket

Un solo servidor puede manejar decenas de miles de conexiones WebSocket, pero escalar más allá de un servidor introduce un problema de coordinación: un mensaje publicado en el Servidor A necesita llegar a clientes conectados al Servidor B.

El Patrón Pub/Sub

Redis pub/sub es la solución estándar:

import { createClient } from 'redis';

const publisher = createClient();
const subscriber = createClient();

await publisher.connect();
await subscriber.connect();

// When a message comes in on any server, publish to Redis
function publishToRoom(roomId: string, message: any) {
  publisher.publish(`room:${roomId}`, JSON.stringify(message));
}

// Each server subscribes and forwards to its local connections
await subscriber.subscribe(`room:${roomId}`, (message) => {
  const data = JSON.parse(message);
  broadcastToLocalClients(roomId, data);
});

Este patrón te permite ejecutar múltiples servidores WebSocket detrás de un load balancer. Cada servidor maneja sus propias conexiones, y Redis coordina la comunicación entre servidores.

Sticky Sessions

Las conexiones WebSocket son stateful — una vez establecidas, deben permanecer en el mismo servidor. Los load balancers necesitan sticky sessions (también llamadas session affinity) para enrutar la conexión WebSocket de un cliente al mismo servidor que manejó el upgrade HTTP inicial.

Con nginx:

upstream websocket_servers {
    ip_hash;  # Sticky sessions based on client IP
    server ws1.example.com:8080;
    server ws2.example.com:8080;
}

server {
    location /ws {
        proxy_pass http://websocket_servers;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 86400;  # 24 hours
    }
}

Tiempo Real vs. Casi Tiempo Real

Hay un espectro entre "verificar actualizaciones cuando el usuario refresca la página" y "entregar cada cambio en milisegundos." La mayoría de las funcionalidades caen en algún punto intermedio.

Enfoque Latencia Complejidad Mejor Para
Refresh de página Iniciado por usuario Ninguna Contenido estático, cambios infrecuentes
Polling (30s) Hasta 30 segundos Baja Conteos de notificaciones, actualizaciones de dashboard
Polling (5s) Hasta 5 segundos Baja Feeds de actividad, estado de pedidos
Long polling Sub-segundo Moderada Sistemas antiguos, entornos hostiles a proxies
SSE Sub-segundo Moderada Feeds en vivo, notificaciones, flujos unidireccionales
WebSockets Sub-segundo Alta Chat, colaboración, tiempo real bidireccional
WebRTC Milisegundos Muy alta Video/audio, peer-to-peer

El enfoque correcto es el más simple que cumpla tu requisito de latencia. Empieza con polling. Si los usuarios se quejan de datos obsoletos, actualiza a SSE. Si necesitas comunicación bidireccional, usa WebSockets. Cada paso hacia arriba en la escalera de complejidad debe estar justificado por una necesidad concreta del usuario, no por un requisito futuro hipotético.

Recomendaciones Prácticas

Después de construir funcionalidades en tiempo real en varios proyectos, estos son los patrones a los que sigo regresando:

Predetermina SSE para tiempo real unidireccional. La mayoría de las funcionalidades en tiempo real son de servidor a cliente: actualizaciones de pedidos, flujos de notificaciones, feeds de datos en vivo. SSE maneja todo esto con menos complejidad que WebSockets.

Usa WebSockets solo para necesidades bidireccionales. Chat, edición colaborativa, interacciones multijugador — estos genuinamente necesitan WebSockets. Si tu cliente solo recibe datos, SSE es más simple.

Siempre implementa reconexión con backoff. Las conexiones se caen. Las redes cambian. Los servidores se reinician. Tu cliente necesita manejar esto con gracia, y tu servidor necesita sobrevivir a una tormenta de reconexión.

Envía diffs, no estado completo. En lugar de enviar la lista completa de pedidos cada vez que un pedido cambia, envía solo el pedido modificado. El cliente aplica el diff a su estado local. Esto reduce el ancho de banda y el procesamiento en ambos lados.

Ten un fallback. Si tu conexión WebSocket falla y no puede reconectarse, la aplicación aún debería ser funcional. Un indicador de "última actualización hace 30 segundos" con refresh manual es mejor que una pantalla en blanco.

Monitorea los conteos de conexión en producción. Las conexiones WebSocket consumen recursos del servidor (memoria, descriptores de archivo). Un pico repentino en conexiones — por un bug del cliente causando reconexiones rápidas, por ejemplo — puede tumbar tu servidor. Alerta sobre anomalías en el conteo de conexiones.

El objetivo no es tener la arquitectura de tiempo real más sofisticada. El objetivo es entregar datos a los usuarios lo suficientemente rápido para que la aplicación se sienta responsive, con la menor cantidad de complejidad de infraestructura que logre esa sensación.

DU

Danil Ulmashev

Full Stack Developer

Interesado en trabajar juntos?