HU

Humano

← Blog

15 de marzo de 2026

100K conexiones WebSocket en 2 servidores — cuando el modelo de concurrencia importa más que el hardware

13 instancias para 100K conexiones WebSocket. El problema: cada conexión consumía 1MB en un thread. Un servidor async dedicado lo bajó a 2 instancias.

websocketrustconcurrenciainfraestructura

Una plataforma SaaS necesita enviar notificaciones en tiempo real a sus usuarios — estado de órdenes, alertas, actualizaciones de dashboards. La implementación usa WebSocket: cada usuario mantiene una conexión persistente con el servidor y recibe mensajes cuando hay algo nuevo. El servicio está construido con Java Spring Boot y Spring WebSocket.

Con 8,000 usuarios concurrentes por instancia, el servicio consume 12.5GB de RAM. Para soportar 100,000 conexiones concurrentes necesitan 13 instancias r5.2xlarge (64GB RAM, 8 vCPUs) a $370/mes cada una — $4,810/mes. Y el 95% del tiempo, esas conexiones no están haciendo nada. Están esperando una notificación que llega cada pocos minutos.

El costo no viene de procesar mensajes. Viene de mantener las conexiones abiertas.

Por qué cada conexión cuesta 1MB

El modelo tradicional de Spring WebSocket asigna un thread del sistema operativo por cada conexión WebSocket activa. En la JVM, cada thread tiene un stack reservado cuyo tamaño por defecto es entre 512KB y 1MB dependiendo de la plataforma (controlado por -Xss). Ese stack se reserva al crear el thread, sin importar si el thread está procesando un mensaje o esperando en TIMED_WAITING.

Con 8,000 conexiones concurrentes por instancia:

ComponenteConsumo
Thread stacks (~1MB × 8,000 threads)~8GB
Java heap (buffers de mensajes, objetos de conexión)~3GB
Metaspace (clases cargadas, Spring context)~1.5GB
Total por instancia~12.5GB

Los threads existen para servir requests que llegan y se completan rápidamente — un modelo que funciona bien para HTTP. Pero una conexión WebSocket de notificaciones se abre cuando el usuario entra a la plataforma y se cierra cuando sale. La conexión puede estar activa 30 minutos, y durante ese tiempo recibe 5 mensajes. El thread existe durante toda la vida de la conexión, consumiendo stack memory mientras hace nada.

Lo que muestra el diagnóstico

Java VisualVM muestra 8,200+ threads vivos por instancia. Un jstack del proceso revela el estado:

El sistema operativo no distingue entre un thread que está procesando y uno que duerme. El scheduler de Linux los mantiene todos en su tabla de procesos. Con 8,200 threads, vmstat muestra ~120,000 context switches por segundo — el kernel rotando entre threads que en su mayoría no tienen trabajo.

El problema se agrava durante broadcast. Cuando un evento debe notificar a todos los usuarios conectados a una instancia, el thread pool executor recibe 8,000 tareas simultáneas. La cola se satura. El tiempo de entrega del último mensaje: 4.2 segundos. Para un dashboard que debería actualizarse en tiempo real, eso es inaceptable.

Las alternativas evaluadas

Antes de decidir una dirección, el equipo evaluó las opciones disponibles:

AlternativaModelo de concurrenciaConexiones estimadas por instanciaLimitación principal
Virtual Threads (JDK 21+)Virtual threads (~few KB de stack)~30,000-50,000Requiere migración a JDK 21+. El soporte de Spring WebSocket para virtual threads es limitado en modo reactivo. No resuelve el fan-out
Spring WebFlux + Reactor NettyEvent loop, non-blocking I/O~30,000-50,000Reescritura significativa desde Spring MVC. El equipo no tiene experiencia con programación reactiva en Java. El modelo de backpressure de Reactor agrega complejidad
Go con goroutinesGoroutines (~4KB cada una)~40,000-60,000Reescritura completa del servicio. Go resuelve la memoria pero el fan-out a 50K goroutines sigue siendo secuencial por la naturaleza de los channels
Node.js con wsEvent loop single-threaded~20,000-30,000Single-threaded: un broadcast a 50K conexiones bloquea el event loop. Escala con cluster mode pero pierde la ventaja de simplicidad
Rust con tokioAsync tasks (~few KB cada una)~50,000+Requiere escribir un servicio nuevo. El equipo no tiene experiencia en Rust

Cada alternativa resuelve parcialmente el problema. Virtual Threads y WebFlux reducen el consumo de memoria pero requieren migración del framework dentro de Java. Go y Node.js requieren reescribir el servicio completo. Ninguna de estas opciones es trivial, y todas requieren que el equipo asuma nueva complejidad en un componente que hoy es crítico.

La decisión: separar el servidor de conexiones

El servidor WebSocket no necesita conocer la lógica de negocio. Su trabajo es simple: mantener conexiones abiertas y entregar mensajes a los clientes correctos. La decisión de qué enviar y a quién se toma en el servicio Java, que ya tiene esa lógica.

La arquitectura resultante:

Java app → Redis Pub/Sub → Rust WebSocket server → clientes

El servicio Java sigue decidiendo cuándo notificar y a quién. Publica eventos en Redis Pub/Sub con el identificador del usuario o grupo destino. El servidor Rust se suscribe a Redis, busca las conexiones activas para ese destino, y entrega el mensaje.

Esta separación permite elegir el runtime óptimo para cada responsabilidad: Java con Spring para la lógica de negocio (donde la productividad del framework importa) y un runtime async para las conexiones persistentes (donde el modelo de memoria importa).

Por qué async/await cambia la ecuación

El runtime tokio ejecuta miles de tasks asíncronas sobre un pool fijo de threads del sistema operativo — típicamente uno por core. Una conexión WebSocket se representa como un async task que pesa pocos KB (el estado de la máquina de estados generada por el compilador, más los buffers de lectura/escritura).

Cuando una conexión está idle (el 95% del tiempo), su task no consume ningún thread. Está registrada en epoll — el mecanismo del kernel de Linux para monitorear miles de file descriptors simultáneamente — y solo se ejecuta cuando hay datos para leer o escribir. Esto es fundamentalmente diferente al modelo thread-per-connection:

AspectoThread por conexión (Java)Async task (tokio)
Memoria por conexión idle~1MB (stack reservado)~8KB (estado del task + buffers)
Threads del OS para 50K conexiones50,0008 (uno por core)
Context switches (50K conns, idle)~120,000/s~3,000/s
Costo de broadcast a 50K conexionesSecuencial, limitado por thread poolAsync I/O batched vía epoll

La diferencia de 125x en memoria por conexión es lo que permite pasar de 8,000 a 50,000 conexiones por instancia.

La implementación

El servidor Rust usa tokio-tungstenite para el protocolo WebSocket sobre el runtime de tokio. El código total son ~800 líneas que se compilan a un binario estático sin dependencias de runtime.

El flujo por conexión:

  1. El cliente establece la conexión WebSocket. tokio crea un async task
  2. El task se registra en un mapa compartido (indexado por usuario/grupo)
  3. El task queda suspendido hasta que hay un mensaje para enviar o el cliente envía datos
  4. Cuando llega un evento de Redis, el servidor busca los tasks destino y les envía el mensaje
  5. Cada task escribe el mensaje al WebSocket de forma asíncrona — sin bloquear threads

El fan-out de un broadcast a 50,000 conexiones toma ~150ms. tokio distribuye las escrituras entre los 8 threads del runtime, y las escrituras se batean a nivel de kernel con epoll. No hay 50,000 llamadas a write() secuenciales — el kernel agrupa las operaciones de I/O.

Esto resuelve el problema del C10K — y de paso el C100K — con hardware modesto.

El resultado

MétricaAntes (Java Spring WebSocket)Después (Rust + tokio)
Conexiones por instancia~8,000~50,000
Memoria por conexión~1MB (thread stack)~8KB (async task + buffers)
RAM total por instancia12.5GB500MB
Instancias para 100K conexiones13 (r5.2xlarge, $370/mes c/u)2 (c5.xlarge, $69/mes c/u)
Costo mensual de infra$4,810/mes$138/mes
Broadcast fan-out (por instancia)4.2s (8K conexiones)150ms (50K conexiones)
Context switches/s~120,000~3,000
CPU en steady state15%2%

Las instancias pasaron de r5.2xlarge (64GB RAM, optimizadas para memoria) a c5.xlarge (8GB RAM, 4 vCPUs) — porque el bottleneck dejó de ser memoria. Con 500MB de consumo por instancia, el factor limitante ahora son los file descriptors del kernel y el ancho de banda de red, no la RAM.

El servicio Java se simplificó: ya no gestiona conexiones WebSocket. Publica eventos en Redis y el servidor Rust los entrega. La complejidad de mantener un componente en Rust se compensa con la reducción de ~$4,670/mes en infraestructura y la eliminación de los timeouts de broadcast.

Cuándo este patrón aplica

Cuándo no

La decisión no es "Rust es mejor que Java para WebSocket". Es que para un servidor de conexiones persistentes donde el 95% del trabajo es mantener la conexión abierta, el modelo de concurrencia del runtime importa más que la potencia del hardware. Agregar RAM no resuelve un problema de modelo de concurrencia — lo subsidia a $4,810/mes.


Si tu sistema mantiene miles de conexiones persistentes y el costo de infraestructura crece linealmente con los usuarios conectados, podemos revisar si la separación del servidor de conexiones tiene sentido en tu caso. Una conversación de 15 minutos sin costo es suficiente para evaluar si hay caso.

Referencias