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.
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:
| Componente | Consumo |
|---|---|
| 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:
- 93% de los threads en
TIMED_WAITING— conexiones WebSocket idle esperando datos - 4% en
RUNNABLE— procesando mensajes activos o haciendo I/O - 3% en otros estados (threads del framework, GC, JIT)
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:
| Alternativa | Modelo de concurrencia | Conexiones estimadas por instancia | Limitación principal |
|---|---|---|---|
| Virtual Threads (JDK 21+) | Virtual threads (~few KB de stack) | ~30,000-50,000 | Requiere 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 Netty | Event loop, non-blocking I/O | ~30,000-50,000 | Reescritura 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 goroutines | Goroutines (~4KB cada una) | ~40,000-60,000 | Reescritura 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 ws | Event loop single-threaded | ~20,000-30,000 | Single-threaded: un broadcast a 50K conexiones bloquea el event loop. Escala con cluster mode pero pierde la ventaja de simplicidad |
| Rust con tokio | Async 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:
| Aspecto | Thread 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 conexiones | 50,000 | 8 (uno por core) |
| Context switches (50K conns, idle) | ~120,000/s | ~3,000/s |
| Costo de broadcast a 50K conexiones | Secuencial, limitado por thread pool | Async 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:
- El cliente establece la conexión WebSocket. tokio crea un async task
- El task se registra en un mapa compartido (indexado por usuario/grupo)
- El task queda suspendido hasta que hay un mensaje para enviar o el cliente envía datos
- Cuando llega un evento de Redis, el servidor busca los tasks destino y les envía el mensaje
- 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étrica | Antes (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 instancia | 12.5GB | 500MB |
| Instancias para 100K conexiones | 13 (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 state | 15% | 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
- Más de 10,000 conexiones persistentes concurrentes (WebSocket, SSE, long-polling)
- La mayoría de las conexiones están idle la mayor parte del tiempo — el patrón de notificación push donde los mensajes llegan cada minutos, no cada milisegundo
- Fan-out o broadcast es una operación crítica del sistema
- La memoria por conexión es el bottleneck de escalabilidad, no el CPU de procesamiento
- La lógica de conexión es separable de la lógica de negocio — el servidor de conexiones es un relay, no un procesador
Cuándo no
- Si las conexiones son de corta duración (request/response HTTP convencional), el overhead del modelo thread-per-connection es despreciable. Un request que dura 50ms no necesita optimización de memoria de conexión
- Si el número de conexiones concurrentes es menor a 2,000, el enfoque de Spring WebSocket funciona y la complejidad de separar el componente no se justifica
- Si el servidor WebSocket necesita integración directa con la lógica de negocio (procesamiento complejo por mensaje, acceso a bases de datos por conexión), separarlo introduce latencia y complejidad de serialización que pueden superar el beneficio
- Si el equipo puede migrar a Virtual Threads (JDK 21+) y el framework lo soporta completamente, el consumo de memoria baja a pocos KB por virtual thread sin cambiar de lenguaje. Es la solución de menor fricción cuando es viable
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
- tokio — An asynchronous Rust runtime — runtime async que ejecuta miles de tasks sobre un pool fijo de OS threads
- tokio-tungstenite — docs.rs — implementación de WebSocket sobre tokio
- Oracle Java 17: java command — Thread Stack Size (-Xss) — default de thread stack size entre 512KB y 1MB dependiendo de la plataforma
- JEP 444: Virtual Threads (Project Loom) — virtual threads en JDK 21+ como alternativa al modelo thread-per-connection
- epoll(7) — Linux manual page — mecanismo del kernel para monitorear múltiples file descriptors, base del I/O async en Linux
- Redis Pub/Sub Documentation — mecanismo de publicación/suscripción usado para comunicar el servicio Java con el servidor de conexiones
- Spring Framework: WebSocket Support — documentación de Spring WebSocket y su modelo de threading
- The C10K problem — Dan Kegel — referencia histórica sobre el desafío de manejar 10,000 conexiones concurrentes