Skip to content
enes
2026-04-17engineering6 min de lectura

Real-time con Socket.io en multi-instancia: lo que aprendí a las trompadas.

Socket.io detrás de un load balancer con varias instancias backend se rompe en tres formas específicas. Cada una te enseña algo que los docs subestimaron.

El tutorial de Socket.io funciona en tu laptop. Un proceso Node, un cliente, los mensajes fluyen. Lo enviás a producción, escalás a dos instancias backend detrás de un load balancer, y los mensajes dejan de llegar a la mitad de los usuarios. Bienvenido a la parte que el tutorial salteó.

El problema no es Socket.io. El problema es que real-time sobre un fleet de backends stateless es una forma distinta a request/response HTTP, y el load balancer no te puede ayudar de la manera en que normalmente ayuda.

El setup

Imaginate una plataforma colaborativa, varios usuarios en la misma página, ediciones y updates de presencia fluyendo en las dos direcciones. El frontend abre una conexión Socket.io. El backend escucha en un server Socket.io. Los mensajes se emiten, los clientes reciben. Hasta que tenés dos backends.

Tres cosas se rompen en el momento que la segunda instancia arranca.

Trampa 1: el handshake no sobrevive al load balancer

El transport de Socket.io tiene fallback. Prueba WebSocket primero; si falla, prueba HTTP long-polling. El long-polling manda una cadena de requests HTTP donde cada request tiene que llegar a la misma instancia backend, porque el estado de la conexión vive en memoria en esa instancia.

Un load balancer naive hace round-robin. El primer request de long-polling pega contra backend A, que crea una sesión. El segundo request pega contra backend B, que no tiene registro de esa sesión y la rechaza. La conexión falla. El cliente reintenta. El retry pega contra backend A o B al azar. A veces se establece, a veces no.

El fix son sticky sessions: el load balancer necesita rutear cada request de un cliente dado a la misma instancia backend por la duración de la sesión. AWS ALB le llama stickiness, configurado por target group con una cookie de duración. La mayoría de los load balancers tiene un equivalente. Sin eso, el long-polling nunca funciona detrás de tu fleet, y cualquier cliente cuya red bloquea WebSocket cae en un loop permanente de reintento.

El tutorial asumió que tu load balancer haría esto. Los load balancers de producción no lo hacen por default.

Trampa 2: un mensaje emitido en instancia A nunca llega a los clientes de instancia B

Sticky sessions levantan la conexión. No resuelven el siguiente problema.

La usuaria Alice está conectada a instancia A. El usuario Bob está conectado a instancia B. Bob hace algo, instancia B maneja la acción y emite un mensaje destinado a Alice. Instancia B tiene la conexión de Bob pero no la de Alice. El mensaje no va a ningún lado. Alice sigue refrescando preguntándose por qué no se actualiza nada.

Este es el caso al que los docs le dedican un párrafo y llaman "el problema del adapter". Socket.io tiene un adapter in-memory por default que sabe de los clientes conectados a esta instancia. Para alcanzar entre instancias, necesitás un adapter compartido, típicamente Redis. El Redis adapter hace pub-sub de cada emit así todas las instancias se enteran; cada instancia reenvía los mensajes a sus propios clientes conectados.

Diagrama: Bob emite a Instancia B, que publica en Redis Pub/Sub. Redis difunde el mensaje a Instancia A, que entrega a Alice. El salto entre instancias pasa por Redis.

El Redis adapter son dos paquetes NPM y un connection string. La carga sobre Redis de esto es chica para la mayoría de los productos, unos cientos de mensajes por segundo no es nada. El costo de saltearlo es que la mitad de tus usuarios pierden la mitad de tus mensajes, de una manera no determinística que es brutal de debuguear porque funciona en staging donde solo tenés una instancia.

Trampa 3: los deploys desconectan a todos, y la tormenta de reconexión duele

Tu fleet rolea. Una imagen nueva se deploya, las instancias drenan, los clientes reconectan. Con cien usuarios concurrentes, un deploy significa cien intentos simultáneos de reconexión. Las instancias nuevas bootean, las pegan todas al mismo tiempo, y la CPU pica a 100% durante la ventana de warm-up. El establecimiento de conexión es más pesado que el tráfico steady-state.

Dos partes de la mitigación:

El lado de la reconexión. El cliente Socket.io soporta backoff de reconexión con jitter. Los defaults están bien; seteá explícitamente reconnectionDelay y reconnectionDelayMax para que la tormenta se distribuya. Usamos una base de 1 segundo, máximo de 30 segundos, suficiente para que las reconexiones individuales sean rápidas mientras se previene un retry sincronizado a través del fleet.

El lado del deploy. La estrategia de instance refresh controla cuántas instancias viejas drenan a la vez. Si el ASG las drena a todas simultáneamente, la tormenta es inevitable. Configurá un rolling deploy con batch size chico y un período de warmup, la mitad del fleet se queda arriba mientras la otra mitad sube, las reconexiones llegan en olas en lugar de todas al mismo tiempo, y la CPU se queda manejable.

La trampa es que nada de esto importa en single-instance dev. Las tormentas de reconexión solo aparecen bajo carga real con un fleet real.

Lo que los docs no profundizan

Una conexión WebSocket es una conexión TCP de larga vida. El idle timeout default de tu load balancer puede matarla después de un minuto de silencio. ALB defaultea a 60 segundos; subilo a varios minutos o mandá pings periódicos de Socket.io para mantener tráfico en el cable. Socket.io tiene un heartbeat incorporado, verificá que esté tuneado para tu ventana de timeout, no al revés.

Si tu aplicación usa rooms de Socket.io, la membresía del room es por-instancia con el adapter in-memory y global con el adapter Redis. Tu código que joinea un room funciona igual; la diferencia está en quién puede emitir al room. Con el adapter Redis, cualquier instancia puede emitir a cualquier room. Sin él, solo la instancia que hostea la conexión puede.

La autorización ocurre en el handshake. El token que el cliente manda se valida cuando la conexión se establece; después de eso, la conexión vive. Si revocás el acceso de un usuario, su conexión socket persiste hasta que reconecta. Decidí si eso es aceptable o si necesitás validación activa de sesión en cada emit, la mayoría de los productos están bien con el gap, los productos sensibles a seguridad no.

La forma que funciona

Sticky sessions en el load balancer. Adapter Redis en cada instancia. Backoff de reconexión tuneado en el cliente. Deploys rolling con batch size controlado. Idle timeout alineado con tu heartbeat. Modelo de autorización que matchee tus necesidades de seguridad.

Ese es el checklist de producción. El tutorial de dev no tiene nada de esto porque dev corre una instancia. Cada equipo construyendo real-time sobre un fleet redescubre cada uno.

La trampa abajo de las trampas: real-time es stateful, y tus asunciones de deploy stateless no cruzan al otro lado. Tratá las conexiones como estado que necesita rutas explícitas de migración de la misma manera que tratarías conexiones de base de datos, y el resto sigue.