SincroDev Logo SincroDev

WebSockets: Conexiones Bidireccionales en Tiempo Real


Imagina que estás en un chat y cada vez que quisieras saber si hay mensajes nuevos, tuvieras que refrescar la página manualmente. Frustrante, ¿verdad? Durante años, la web funcionó así: el cliente preguntaba, el servidor respondía, y la conexión se cerraba. Fin de la historia.

WebSockets cambió las reglas del juego. Permite que cliente y servidor mantengan una conversación abierta y continua, donde cualquiera de los dos puede enviar mensajes en cualquier momento.

El problema con HTTP tradicional

HTTP fue diseñado como un protocolo de petición-respuesta:

Cliente                          Servidor
   |                                |
   |-------- GET /mensajes -------->|
   |<------- [respuesta] -----------|
   |         (conexión cerrada)     |
   |                                |
   |-------- GET /mensajes -------->|
   |<------- [respuesta] -----------|
   |         (conexión cerrada)     |

Para simular “tiempo real”, los desarrolladores inventaron técnicas como:

TécnicaCómo funcionaProblema
PollingPreguntar cada X segundosDesperdicia recursos, latencia alta
Long PollingMantener petición abierta hasta que haya datosComplejo, reconexiones constantes
Server-Sent EventsServidor envía eventos al clienteUnidireccional (solo servidor → cliente)

Ninguna era ideal para comunicación bidireccional real.

WebSockets: la solución elegante

WebSockets establece una conexión persistente y full-duplex sobre TCP:

Cliente                          Servidor
   |                                |
   |==== Handshake HTTP Upgrade ===>|
   |<=== 101 Switching Protocols ===|
   |                                |
   |====== WebSocket abierto =======|
   |                                |
   |<-------- mensaje --------------|
   |--------- mensaje ------------->|
   |<-------- mensaje --------------|
   |--------- mensaje ------------->|
   |                                |
   |  (conexión permanece abierta)  |

Características clave

  • Full-duplex: Ambas partes pueden enviar datos simultáneamente
  • Persistente: Una sola conexión TCP para toda la sesión
  • Baja latencia: Sin overhead de headers HTTP por cada mensaje
  • Eficiente: Frames mínimos de 2-14 bytes vs cientos en HTTP

El handshake: de HTTP a WebSocket

Todo comienza con una petición HTTP normal con headers especiales:

GET /chat HTTP/1.1
Host: servidor.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

El servidor responde aceptando el “upgrade”:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

El valor de Sec-WebSocket-Accept es un hash del Sec-WebSocket-Key concatenado con un GUID específico, probando que el servidor entiende WebSockets.

A partir de este momento, la conexión TCP deja de hablar HTTP y comienza el protocolo WebSocket.

Anatomía de un frame WebSocket

Una vez establecida la conexión, los datos viajan en frames:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+-------------------------------+
|     Extended payload length continued, if payload len == 127  |
+-------------------------------+-------------------------------+
|                               | Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued)       |          Payload Data         |
+-------------------------------+-------------------------------+
|                     Payload Data continued ...                |
+---------------------------------------------------------------+

Campos importantes

CampoBitsDescripción
FIN1Indica si es el frame final del mensaje
Opcode4Tipo: texto (0x1), binario (0x2), close (0x8), ping (0x9), pong (0xA)
MASK1Los mensajes del cliente DEBEN estar enmascarados
Payload length7+Longitud de los datos

Tamaño mínimo

Un mensaje pequeño puede viajar en solo 2 bytes de overhead (sin máscara) o 6 bytes (con máscara). Compara esto con los 500-800 bytes de headers HTTP típicos.

Implementación práctica

Cliente (JavaScript)

// Crear conexión
const ws = new WebSocket('wss://servidor.com/chat');

// Conexión establecida
ws.onopen = () => {
  console.log('Conectado!');
  ws.send(JSON.stringify({ tipo: 'saludo', mensaje: 'Hola!' }));
};

// Recibir mensajes
ws.onmessage = (evento) => {
  const datos = JSON.parse(evento.data);
  console.log('Mensaje recibido:', datos);
};

// Manejar errores
ws.onerror = (error) => {
  console.error('Error WebSocket:', error);
};

// Conexión cerrada
ws.onclose = (evento) => {
  console.log(`Desconectado: ${evento.code} - ${evento.reason}`);
};

Servidor (Node.js con ws)

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

// Set para trackear clientes conectados
const clientes = new Set();

wss.on('connection', (ws) => {
  clientes.add(ws);
  console.log(`Cliente conectado. Total: ${clientes.size}`);

  ws.on('message', (data) => {
    const mensaje = JSON.parse(data);
    console.log('Recibido:', mensaje);

    // Broadcast a todos los clientes
    for (const cliente of clientes) {
      if (cliente.readyState === ws.OPEN) {
        cliente.send(JSON.stringify({
          tipo: 'broadcast',
          de: 'servidor',
          contenido: mensaje
        }));
      }
    }
  });

  ws.on('close', () => {
    clientes.delete(ws);
    console.log(`Cliente desconectado. Total: ${clientes.size}`);
  });
});

console.log('Servidor WebSocket en puerto 8080');

Ping/Pong: manteniendo la conexión viva

WebSockets incluye un mecanismo de heartbeat para detectar conexiones muertas:

Cliente                          Servidor
   |                                |
   |<-------- Ping frame -----------|
   |--------- Pong frame ---------->|
   |                                |
   |  (conexión verificada activa)  |

Los frames de ping/pong son de control (opcode 0x9 y 0xA) y pueden llevar datos de hasta 125 bytes. El navegador responde automáticamente a los pings.

Configuración típica

// Servidor envía ping cada 30 segundos
const intervalo = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) {
      return ws.terminate();
    }
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => {
    ws.isAlive = true;
  });
});

Seguridad: WSS y consideraciones

WSS (WebSocket Secure)

Siempre usa wss:// en producción. Funciona igual que HTTPS:

ws://  → WebSocket sobre TCP (puerto 80)
wss:// → WebSocket sobre TLS/TCP (puerto 443)

Validaciones importantes

wss.on('connection', (ws, request) => {
  // 1. Verificar origen
  const origen = request.headers.origin;
  if (!origenesPermitidos.includes(origen)) {
    ws.close(1008, 'Origen no permitido');
    return;
  }

  // 2. Autenticar (token en query o primer mensaje)
  const token = new URL(request.url, 'http://localhost').searchParams.get('token');
  if (!verificarToken(token)) {
    ws.close(1008, 'No autorizado');
    return;
  }

  // 3. Rate limiting
  if (excedeLimite(request.socket.remoteAddress)) {
    ws.close(1008, 'Demasiadas conexiones');
    return;
  }
});

Casos de uso ideales

AplicaciónPor qué WebSockets
Chat en tiempo realMensajes instantáneos bidireccionales
Juegos multijugadorSincronización de estado con baja latencia
Trading/FinanzasActualizaciones de precios en milisegundos
Colaboración en vivoGoogle Docs, Figma, editores compartidos
Notificaciones pushAlertas inmediatas sin polling
IoT y telemetríaStreaming de datos de sensores

WebSockets vs alternativas

CriterioWebSocketsSSELong PollingHTTP/2 Push
DirecciónBidireccionalSolo servidor→clienteBidireccional (simulado)Solo servidor→cliente
ConexiónPersistentePersistenteReconexionesPersistente
OverheadMuy bajoBajoAltoMedio
Soporte navegadorUniversalMuy buenoUniversalBueno
ComplejidadMediaBajaAltaAlta

Regla práctica: Si necesitas que el cliente envíe datos frecuentemente, usa WebSockets. Si solo el servidor envía actualizaciones, considera SSE por su simplicidad.

Escalando WebSockets

El desafío de escalar conexiones persistentes:

                    ┌─────────────┐
                    │ Load        │
                    │ Balancer    │
                    │ (sticky)    │
                    └──────┬──────┘
           ┌───────────────┼───────────────┐
           │               │               │
     ┌─────┴─────┐   ┌─────┴─────┐   ┌─────┴─────┐
     │ Server 1  │   │ Server 2  │   │ Server 3  │
     │ (1000 ws) │   │ (1000 ws) │   │ (1000 ws) │
     └─────┬─────┘   └─────┬─────┘   └─────┬─────┘
           │               │               │
           └───────────────┼───────────────┘

                    ┌──────┴──────┐
                    │   Redis     │
                    │   Pub/Sub   │
                    └─────────────┘

Sticky sessions

Las conexiones WebSocket deben mantenerse en el mismo servidor:

upstream websocket {
    ip_hash;  # Sticky por IP
    server server1:8080;
    server server2:8080;
    server server3:8080;
}

Pub/Sub para broadcast

Para enviar mensajes a usuarios en diferentes servidores:

import Redis from 'ioredis';

const pub = new Redis();
const sub = new Redis();

// Suscribirse al canal
sub.subscribe('chat:broadcast');
sub.on('message', (canal, mensaje) => {
  // Enviar a todos los clientes locales
  wss.clients.forEach(ws => ws.send(mensaje));
});

// Publicar mensaje (desde cualquier servidor)
pub.publish('chat:broadcast', JSON.stringify({ texto: 'Hola a todos!' }));

Códigos de cierre

Cuando una conexión se cierra, incluye un código que indica la razón:

CódigoNombreSignificado
1000Normal ClosureCierre limpio, operación completada
1001Going AwayServidor apagándose o navegador cerrando
1002Protocol ErrorError en el protocolo WebSocket
1003Unsupported DataTipo de datos no soportado
1006Abnormal ClosureConexión cerrada sin frame de close
1008Policy ViolationMensaje viola política del servidor
1011Internal ErrorError inesperado en el servidor
ws.close(1000, 'Sesión terminada por el usuario');

Conclusión

WebSockets transformó la web de un modelo de petición-respuesta a uno de comunicación continua y bidireccional. Sus ventajas clave:

  • Baja latencia: Ideal para tiempo real
  • Eficiencia: Mínimo overhead por mensaje
  • Simplicidad: API limpia en navegadores
  • Flexibilidad: Soporta texto y binario

La próxima vez que uses un chat, juegues online, o veas precios actualizarse al instante, recuerda: hay un WebSocket trabajando detrás de escena, manteniendo esa conversación abierta entre tu navegador y el servidor.


Este artículo es parte de la serie sobre Protocolos de Red.