Cada vez que Slack muestra un mensaje nuevo sin que refresques la página, o que un tablero de trading actualiza un precio cada milisegundo, hay una conexión abierta detrás que nunca se cierra: el protocolo WebSocket. A diferencia de una petición HTTP normal, que se abre, responde y se cierra, WebSocket mantiene un canal bidireccional activo entre el navegador y el servidor durante toda la sesión.
Pensalo como la diferencia entre mandar cartas y tener una llamada telefónica abierta: con HTTP, cada intercambio es una carta que vas y volvés a buscar al correo; con WebSocket, levantás el teléfono una vez y ambos lados hablan cuando quieren, sin colgar. Este artículo explica cómo funciona el protocolo por dentro, cómo implementarlo con código real, y cuándo conviene usarlo frente a alternativas como Server-Sent Events o HTTP polling.
TL;DR
- Cómo el handshake HTTP inicial usa el header Upgrade y el código 101 para abrir una conexión WebSocket persistente.
- Cómo levantar un servidor WebSocket en Node.js con la librería ws en menos de diez líneas de código.
- Cómo diferenciar WebSocket de HTTP polling, Server-Sent Events y WebTransport, y cuándo elegir cada uno.
- Por qué una conexión WebSocket se cae detrás de un proxy sin heartbeat, y cómo evitarlo con ping y pong.
- Cómo está estructurado un frame WebSocket (opcode, máscara, longitud) y por qué el cliente siempre enmascara.
- Cómo escalar WebSocket a varios servidores con Redis pub/sub sin perder mensajes entre instancias.
- Cómo verificar con curl o las devtools que una conexión realmente completó el upgrade a WebSocket.
Qué es WebSocket y por qué importa
WebSocket es un protocolo de comunicación que corre sobre una única conexión TCP y permite que cliente y servidor se envíen datos en cualquier momento, sin que uno tenga que preguntarle al otro. Fue estandarizado en 2011 por el IETF en la RFC 6455, y hoy lo implementan de forma nativa todos los navegadores modernos a través de la API WebSocket del DOM.
La diferencia con HTTP tradicional es estructural. En HTTP, cada intercambio es una petición y una respuesta: el cliente pide, el servidor responde, y la conexión queda ociosa hasta la próxima petición. Si el servidor necesita avisarle algo al cliente sin que este pregunte, no tiene forma de hacerlo. WebSocket invierte esa regla: una vez completado el handshake inicial, ambos lados pueden enviar mensajes de forma independiente, sin pedir permiso.
Esto importa porque buena parte de la web moderna es reactiva en tiempo real: chats, tableros de trading, notificaciones push, cursores colaborativos en documentos, multijugador en el navegador. Construir eso con HTTP puro obliga a técnicas como el polling (preguntar cada N segundos) o el long polling (dejar la petición abierta hasta que haya novedades), ambas más lentas y más costosas en servidor que un WebSocket abierto.
La conexión persiste después del intercambio HTTP inicial, sin nuevas peticiones por mensaje.
Cómo funciona el handshake y los frames
Todo empieza con una petición HTTP normal que pide un upgrade de protocolo. El cliente manda una cabecera Upgrade: websocket junto con una clave aleatoria en Sec-WebSocket-Key:
GET /chat HTTP/1.1
Host: ejemplo.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Si el servidor acepta, responde con el código 101 (Switching Protocols) y confirma la clave concatenándola con un GUID fijo definido en la RFC 6455 (258EAFA5-E914-47DA-95CA-C5AB0DC85B11), calculando un SHA-1 y codificándolo en base64:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
El handshake también puede negociar un subprotocolo con el header Sec-WebSocket-Protocol, útil cuando cliente y servidor necesitan acordar un formato de mensaje específico, por ejemplo graphql-ws para suscripciones de GraphQL sobre WebSocket.
A partir de ahí, la misma conexión TCP deja de hablar HTTP y empieza a intercambiar frames binarios en ambas direcciones. El siguiente diagrama muestra el ciclo completo:
sequenceDiagram
participant N as Navegador
participant S as Servidor
N->>S: GET /chat, upgrade a websocket
S-->>N: 101 Switching Protocols
Note over N,S: la conexion TCP permanece abierta
N->>S: envia frame de texto (hola)
S-->>N: responde frame de texto (eco hola)
Note over N,S: cualquiera envia frames sin esperar turno
El handshake ocurre una sola vez. Después, cualquiera de los dos lados puede enviar un frame en cualquier momento, sin esperar turno. Eso es lo que hace posible que un servidor le empuje un mensaje al cliente sin que este haya preguntado nada.
Ejemplos prácticos
El ejemplo más simple es un eco: el servidor repite lo que el cliente le manda. Con la librería ws de Node.js, un servidor completo entra en pocas líneas:
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (socket) => {
socket.on('message', (data) => {
socket.send(`eco: ${data}`);
});
});
Este servidor escucha en el puerto 8080 y, por cada mensaje que recibe, responde con el mismo texto precedido de "eco: ". Del lado del navegador no hace falta ninguna librería: la API WebSocket ya viene incluida:
const socket = new WebSocket('ws://localhost:8080');
socket.addEventListener('open', () => {
socket.send('hola servidor');
});
socket.addEventListener('message', (event) => {
console.log('recibido:', event.data);
});
Al abrir esta página con el servidor corriendo, la consola del navegador va a imprimir recibido: eco: hola servidor apenas se completa la conexión.
Un caso más realista es un chat donde cualquier mensaje que llega se reenvía a todos los clientes conectados, no solo a quien lo envió. La diferencia con el ejemplo anterior es que el servidor necesita llevar un registro de los sockets activos:
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
const clientes = new Set();
wss.on('connection', (socket) => {
clientes.add(socket);
socket.on('message', (data) => {
for (const cliente of clientes) {
if (cliente.readyState === cliente.OPEN) {
cliente.send(data.toString());
}
}
});
socket.on('close', () => clientes.delete(socket));
});
Acá clientes es un Set que crece con cada nueva conexión y se limpia cuando alguien se desconecta. La condición readyState === cliente.OPEN evita el error clásico de intentar escribir en un socket que ya se cerró: sin ese chequeo, el servidor lanza una excepción no capturada la primera vez que un cliente cierra la pestaña a mitad de un envío.
Cómo empezar paso a paso
Para probar esto en tu máquina no hace falta más que Node.js instalado. Los pasos:
Primero, crear un proyecto e instalar la librería:
mkdir websocket-demo && cd websocket-demo
npm init -y
npm install ws
Después, guardar el código del servidor de eco de arriba en servidor.js y arrancarlo:
node servidor.js
Por último, verificar la conexión sin escribir HTML, usando la herramienta de línea de comandos wscat:
npx wscat -c ws://localhost:8080
Al escribir un mensaje en esa terminal y presionar Enter, wscat debería mostrar la respuesta eco: (tu mensaje) casi de inmediato. Si en cambio aparece un error de conexión rechazada, confirmá que el servidor sigue corriendo en la otra terminal y que el puerto 8080 no está ocupado por otro proceso.
También podés confirmar el upgrade de protocolo directamente con curl, sin ninguna librería de WebSocket:
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" http://localhost:8080/
Una respuesta con HTTP/1.1 101 Switching Protocols confirma que el servidor completó el upgrade. En las devtools del navegador, la pestaña Network filtrada por "WS" muestra el mismo código 101 y, debajo, cada frame individual que se envió o recibió durante la sesión.
Casos de uso reales
WebSocket resuelve bien un puñado de escenarios donde la latencia por mensaje importa más que la simplicidad de implementación:
- Chats y mensajería: aplicaciones como Slack o Discord usan conexiones persistentes para entregar mensajes sin que el cliente pregunte.
- Tableros financieros: plataformas de trading empujan cambios de precio en tiempo real, donde un retraso de segundos afecta decisiones.
- Edición colaborativa: editores de texto compartido transmiten cada cambio de cursor y cada edición al resto de los participantes conectados.
- Multijugador en navegador: juegos que sincronizan posición y estado entre jugadores sin recargar la página.
- Dashboards de monitoreo: paneles que reflejan alertas o métricas apenas cambian, sin refrescar manualmente.
En todos estos casos, el costo de abrir y cerrar una conexión HTTP por cada actualización, con sus cabeceras, su negociación TLS si aplica y su round-trip completo, supera el costo de mantener un socket abierto, sobre todo cuando las actualizaciones llegan varias veces por segundo.
Cada instancia de servidor necesita compartir mensajes vía pub/sub para llegar a todos los clientes.
Errores comunes y buenas prácticas
El error más frecuente es no planear la reconexión. Una conexión WebSocket se puede caer por un cambio de red, un proxy que cierra conexiones inactivas, o simplemente porque el servidor reinicia. Si el cliente no reintenta con un backoff exponencial, la aplicación se queda colgada en silencio sin que el usuario lo note.
⚠️ Ojo: muchos proxies corporativos y balanceadores de carga cierran conexiones TCP inactivas por más de 60 segundos, aunque el WebSocket siga técnicamente abierto. La solución estándar es un heartbeat: el servidor manda un frame de tipo ping cada cierto intervalo y espera el pong correspondiente; si no llega, cierra la conexión y el cliente reconecta.
Otro problema típico aparece al escalar a más de un proceso de servidor. Si tenés dos instancias detrás de un balanceador de carga, un mensaje que llega a la instancia A no le llega automáticamente a un cliente conectado a la instancia B. La solución habitual es un canal de pub/sub compartido, siendo Redis la opción más común, donde cada instancia publica los mensajes que recibe y se suscribe a los que publican las demás.
También es fácil olvidarse de validar el header Origin en el handshake. A diferencia de una petición fetch normal, WebSocket no aplica CORS por defecto: cualquier página puede intentar abrir una conexión a tu servidor. Si el servidor no valida el origen, queda expuesto a lo que se conoce como cross-site WebSocket hijacking, donde una página maliciosa abre una conexión autenticada usando las cookies de sesión del usuario sin que este lo note.
Por último, enviar mensajes muy grandes sin fragmentarlos genera presión de memoria en ambos extremos. Si tu aplicación transmite archivos o payloads pesados, conviene partirlos en fragmentos y usar el bit FIN del frame para indicar cuándo termina el mensaje completo, en lugar de mandar todo de una vez.
Comparativa con alternativas
Ninguna de estas opciones es universalmente mejor: la elección depende de si necesitás que el cliente también hable, y de cuánta infraestructura estás dispuesto a mantener.
OpciónCuándo usarlaVentajaLimitación
WebSocketComunicación bidireccional frecuente (chat, juegos, trading)Full-duplex real, baja latencia por mensajeRequiere manejar reconexión y escalado manual
HTTP pollingActualizaciones poco frecuentes, prototipos rápidosSimple, sin infraestructura especialLatencia alta y carga innecesaria en el servidor
Server-Sent EventsSolo el servidor necesita empujar datos (notificaciones, logs)Reconexión automática incluida en el navegadorUnidireccional: el cliente no responde por el mismo canal
WebTransportApps nuevas que ya corren sobre HTTP/3Múltiples streams independientes sobre QUICSoporte de navegadores todavía más limitado que WebSocket
Profundizando: cómo funciona por dentro
Por dentro, cada mensaje WebSocket viaja como uno o más frames con una estructura fija definida en la RFC 6455: un bit FIN que indica si es el último fragmento, un opcode de 4 bits que dice si el payload es texto (0x1), binario (0x2), o una señal de control como cierre (0x8), ping (0x9) o pong (0xA), y un campo de longitud que puede ocupar 7, 16 o 64 bits según el tamaño del payload.
Un detalle poco conocido: todo frame que viaja del cliente al servidor debe estar enmascarado con una clave de 4 bytes generada al azar, algo que no aplica en la dirección contraria. Esto no es una medida de seguridad contra atacantes que ya controlan el tráfico, sino una defensa específica contra proxies de caché mal configurados: sin la máscara, un atacante podría construir un payload que un proxy intermedio interprete como una petición HTTP válida y la cachee o reenvíe de forma indebida.
El protocolo también define en la RFC 6455 códigos de cierre estandarizados: 1000 significa cierre normal, 1006 indica que la conexión se cortó de forma anormal (sin frame de cierre explícito, típico de una caída de red), y 1011 señala que el servidor encontró una condición inesperada. Revisar el código de cierre en el evento close del cliente es la forma correcta de decidir si conviene reconectar automáticamente o mostrarle un error al usuario.
Para reducir el tráfico existe la extensión permessage-deflate, negociada durante el handshake, que comprime cada mensaje con DEFLATE antes de enviarlo. Es útil para payloads de texto repetitivo, como JSON con muchas claves iguales, pero añade costo de CPU por mensaje, así que no siempre conviene activarla en cargas con muchísimos mensajes pequeños.
💡 Tip: si tu aplicación ya usa Socket.IO y no necesitás las salas ni el fallback automático a HTTP, migrar al WebSocket nativo del navegador elimina una dependencia entera y reduce el tamaño del bundle del cliente.
Vale la pena distinguir WebSocket puro de librerías como Socket.IO, que agregan sobre el protocolo cosas como reconexión automática, acknowledgments por mensaje, salas (rooms) para agrupar clientes, y un mecanismo de fallback a HTTP long polling cuando el WebSocket no está disponible. La ventaja es productividad; la contrapartida es que Socket.IO define su propio formato de mensaje sobre los frames, así que un cliente Socket.IO no puede hablar directamente con un servidor que solo implementa la API WebSocket nativa.
Arquitectura para escalar a múltiples servidores
Cuando una sola instancia de servidor no alcanza, agregar más procesos detrás de un balanceador rompe la suposición de que todos los clientes comparten memoria. El siguiente diagrama muestra el patrón habitual con un canal de pub/sub compartido:
flowchart TD
A["Navegador"] --> B["Balanceador de carga"]
B --> C["Servidor WS 1"]
B --> D["Servidor WS 2"]
C --> E[("Redis pub-sub")]
D --> E
subgraph Backend
C
D
E
end
Cada servidor publica en Redis los mensajes que recibe de sus propios clientes y se suscribe al mismo canal para recibir los que publican las demás instancias. Así, un mensaje que entra por el servidor 1 puede llegar a un cliente conectado al servidor 2 sin que ambos procesos compartan memoria directamente.
📖 Resumen en Telegram: Ver resumen
Tu próximo paso: cloná el servidor de eco de este artículo, corré node servidor.js y conectate con npx wscat -c ws://localhost:8080 para ver el protocolo funcionando en vivo en menos de cinco minutos.
Preguntas frecuentes
¿WebSocket funciona sobre HTTP o sobre TCP?
Empieza como una petición HTTP normal (el handshake), pero una vez que el servidor responde con el código 101, la conexión pasa a hablar el protocolo WebSocket directamente sobre el socket TCP subyacente, ya sin cabeceras HTTP en cada mensaje.
¿Necesito un servidor especial para usar WebSocket?
No hace falta un servidor dedicado: librerías como ws en Node.js, o soporte nativo en frameworks como Django Channels o Spring WebSocket, agregan la capacidad de manejar el protocolo sobre el mismo proceso que ya sirve HTTP.
¿Cuál es la diferencia entre WebSocket y Server-Sent Events?
Server-Sent Events solo permite que el servidor le envíe datos al cliente, sobre una conexión HTTP normal con reconexión automática incluida. WebSocket es bidireccional: el cliente también puede enviar mensajes por el mismo canal sin abrir una nueva conexión.
¿WebSocket funciona con HTTPS?
Sí. La versión cifrada se identifica con el esquema wss:// en lugar de ws://, y corre sobre TLS igual que HTTPS. En producción es la opción recomendada, sobre todo si la conexión viaja por redes no confiables.
¿Cómo escalo WebSocket a miles de conexiones simultáneas?
El límite práctico suele ser la memoria del proceso, ya que cada conexión abierta consume descriptores de archivo y buffers, más que el protocolo en sí. La estrategia habitual combina múltiples instancias de servidor detrás de un balanceador con soporte de conexiones persistentes, coordinadas con un canal de pub/sub compartido como Redis.
¿Qué pasa si el navegador pierde la conexión a mitad de una sesión?
El evento close se dispara en el cliente con un código que indica el motivo. Una implementación robusta escucha ese evento y reintenta la conexión con un backoff exponencial, en lugar de asumir que el usuario cerró la pestaña intencionalmente.
Referencias
- RFC 6455: especificación oficial del protocolo WebSocket publicada por el IETF en 2011.
- MDN: WebSocket API: documentación de referencia de la API nativa del navegador.
- ws en GitHub: implementación de servidor y cliente WebSocket para Node.js usada en los ejemplos de este artículo.
- Socket.IO en GitHub: librería que agrega reconexión, salas y fallback sobre WebSocket.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
Top comments (0)