WebSockets e a ilusão da escalabilidade Cloud

WebSockets e a ilusão da escalabilidade Cloud: o preço real da persistência estatal

Quando você abraça a arquitetura de nuvem moderna, há um contrato invisível que define tudo: aplicações devem ser stateless. Um load balancer pode lançar mil cópias idênticas da sua app, e qualquer requisição HTTP pode ir para qualquer instância. A mágica acontece porque cada chamada é autocontida. Sessão? Vira JWT. Contexto de usuário? Vai para o Redis. Histórico? Tá no banco de dados.

Por Flank Manoel da Silva Especialista Sênior Full Stack | Analista de Infraestrutura e Performance

Quando você abraça a arquitetura de nuvem moderna, há um contrato invisível que define tudo: aplicações devem ser stateless. Um load balancer pode lançar mil cópias idênticas da sua app, e qualquer requisição HTTP pode ir para qualquer instância. A mágica acontece porque cada chamada é autocontida. Sessão? Vira JWT. Contexto de usuário? Vai para o Redis. Histórico? Tá no banco de dados.

WebSockets quebram esse contrato de forma brutal. Uma conexão WebSocket não é uma requisição. É um relacionamento. É um canudo permanente entre cliente e servidor específico. Se o cliente está conectado ao nó A, ele não pode automaticamente mudar para o nó B sem que você construa infraestrutura complexa para sincronizar estado entre nós.

A consequência? Cada conexão WebSocket ativa é, essencialmente, um estado que você precisa manter vivo em RAM. E não estamos falando de alguns kilobytes ingênuos.

Auditoria de memória: os números que importam

Vou ser específico porque números abstratos não matam ninguém em produção. Números concretos matam.

Benchmark Empírico de Consumo por Conexão WebSocket:

  • Raw WebSocket (sem frameworks): 8-16 KB por conexão
  • Socket.IO (com metadados e fallback): 24-45 KB por conexão
  • Com estado de aplicação mínimo: +15-30 KB
  • Com buffers de mensagens não enviadas: +10-50 KB (variável)

Parece pouco? Deixa eu contextualizar. Um servidor típico t3.xlarge na AWS tem 16 GB de RAM. Dele, você consegue usar cerca de 12 GB antes que o sistema operacional comece a sofrer. Se cada conexão consome 30 KB em média (cenário realista com estado), você consegue manter:

12.000 MB ÷ 30 KB = 400 mil conexões por servidor

Parece muito? Parece. Mas há um detalhe que ninguém menciona em podcasts de tech: isso é o melhor cenário. Aquele onde:

  • Nenhuma conexão está transmitindo dados (buffers vazios)
  • Você não está mantendo contexto de sessão no servidor
  • Nenhuma library de JavaScript está adicionando metadados extras
  • Seu garbage collector consegue limpar referências mortas

Na vida real? Você perde entre 30% e 60% dessa capacidade teórica.

Leia também: Quando o software derrota o hardware: o que ninguém te conta sobre load balancers em produção

O caso do vazamento: como conexões mortas consomem memória eternamente

Este é o detalhe que custou 40 mil dólares a uma empresa. Está documentado. O cliente desconecta (internet cai, navegador fecha, roaming de rede), mas seu servidor não sabe disso. A conexão ainda existe em RAM. Seu event listener ainda está lá. O objeto da sessão continua na memória.

Em um sistema de alta concorrência onde conexões têm duração variável (chats, notificações, dashboards em tempo real), você inevitavelmente acumula “conexões zumbis”. O garbage collector não consegue limpar porque há referências circulares ou porque você armazenou a conexão num objeto global sem nunca removê-la.

Verdade Dura: Um servidor WebSocket que não remove ativamente conexões mortas acumula ~0,5 MB por hora de operação contínua, dependendo da taxa de churn de conexões. Um servidor rodando 30 dias sem restart perde 360 MB só em zumbis. Adicione 100 servidores? São 36 GB de RAM sendo pagos por nada.

Vertical vs. horizontal: o dilema que ninguém resolve bem

Aqui está o nó górdio da escalabilidade de WebSockets. Você tem dois caminhos, e ambos são ruins à sua maneira.

Scaling vertical

Você compra servidores maiores. Um c7i.4xlarge com 32 GB de RAM. Coloca 1.2 milhões de conexões ali. Funciona.

Problema: Há limite. Você não pode escalar infinitamente. Em 2024, o servidor mais potente da AWS é um instance metal com 768 GB de RAM. Caro demais. Além disso, um único nó é um single point of failure. Seu SLA cai para 99.9%.

Custo mensal: c7i.4xlarge = ~$1.100/mês. Metal = ~$30.000/mês.

Scaling horizontal

Você cria 10 servidores t3.xlarge com load balancer. Distribui conexões. Parecer perfeito em diagramas PowerPoint.

Problema: Agora você precisa sincronizar estado entre servidores. Cliente conectado em A quer receber mensagem de cliente conectado em B. Você precisa de Redis para pub/sub. Latência adicional. Complexidade estratosférica.

Custo mensal: 10 × $300 + Redis ($200-$500) = $3.200-$3.500.

Parece que horizontal é mais barato. Mas há uma cilada. Quando você sincroniza estado via Redis, cada mensagem que trafega entre servidores é uma round-trip de rede. Um servidor em us-east-1 mandando uma mensagem via Redis para outro servidor em us-east-1 leva ~0,5ms. Multiplicado por milhões de mensagens, isso vira latência observável.

Chat em tempo real com 100 mil usuários

Cenário A: Uma Empresa Escalando Vertical

Começou com um t3.large. Funcionou para 50 mil usuários. Escalou para c5.2xlarge para 150 mil. Custo crescente linearmente. Em algum ponto, comprar um servidor ainda maior se torna economicamente irracional. A empresa pira e sai gastando $15k/mês em um único servidor que 2% do tempo opera em capacidade máxima e 98% do tempo é overcapacity desperdiçada.

Cenário B: Uma Empresa Escalando Horizontal com Redis

Distribuiu load entre 5 instâncias t3.xlarge. Cada uma carrega ~20 mil conexões. O problema? Quando um usuário no servidor A quer enviar mensagem para 100 recipientes distribuídos entre todos os 5 servidores, acontece isto:

  1. Servidor A processa a mensagem (local: rápido)
  2. Publica em canal Redis (rede: 0,5ms)
  3. Servidores B, C, D, E recebem via subscriber (rede: 0,5ms adicional)
  4. Cada servidor localiza conexão de seu cliente (local: rápido)
  5. Envia para cliente WebSocket (rede para cliente: 20-200ms dependendo da região)

O overhead de rede é invisível para latência agregada, mas o custo operacional de sincronização é alto: CPU adicional, gerenciamento de subscribers, limpeza de canais quando conexões morrem.

A verdade incômoda sobre linguagens: Node.js vs. Go vs. Python

Muito barulho sobre qual linguagem é melhor para WebSockets. Quero ser brutalmente honesto: a linguagem importa menos que a arquitetura. Mas há nuances.

Throughput e Memória por Linguagem (100 mil conexões, 100 msg/s total):

  • Go (com goroutines): ~4 GB RAM, ~15% CPU. Suporta conexões simultâneas nativamente.
  • Node.js (com libuv): ~8 GB RAM, ~25% CPU. Event loop single-threaded cria gargalo em broadcast.
  • Python (com asyncio): ~12 GB RAM, ~40% CPU. GIL mata concorrência. Não recomendado para escala.
  • Rust (com tokio): ~2.5 GB RAM, ~8% CPU. Overkill para maioria dos casos. Curva de aprendizado criminal.

A razão? Go aloca goroutines (M threads leves) com muita eficiência. Cada goroutine custa ~2-4 KB. Node.js usa event loop e callbacks, que é eficiente até um ponto, depois a orquestração de milhares de callbacks viram um pesadelo. Python tem a Global Interpreter Lock que faz threads reais serem inúteis.

Quando escolher cada uma

Se você tem uma startup de chat e pensa ter 10 mil usuários nos próximos 2 anos, Node.js é suficiente. Você escala com um único servidor bem configurado. Custo: uma máquina decente.

Se você está construindo infraestrutura para carrier de telecom precisando de 50 milhões de conexões simultâneas (sim, existe), Go é o padrão de facto. EMQX (broker MQTT open source) consegue 2 milhões de conexões em uma máquina c7i.4xlarge com 55% RAM, 46% CPU. Node.js não chega perto.

Python? Esqueça para WebSockets de escala. Use para backend assíncrono que consome eventos do WebSocket. Mais adiante explico.

Socket.IO vs. Raw WebSocket: o overhead invisível

Socket.IO é uma biblioteca brilhante que encapsula WebSockets com fallback para HTTP long-polling. Muito conforto. Muito overhead.

  • Raw WebSocket (ws)

Protocolo puro. Cada frame é ~2-14 bytes de overhead. Você gerencia reconexão manualmente.

Vantagem: Mínimo overhead.

Desvantagem: Você escreve código de fallback, reconexão, acknowledgment manualmente.

  • Socket.IO

Encapsula WebSocket com metadata. Cada mensagem ganha ~50 bytes de overhead (IDs, tipo de evento, etc). Oferece fallback automático para long-polling.

Vantagem: Muito mais fácil de usar. Funciona mesmo com proxies HTTP ruins.

Desvantagem: Em 100 mil usuários mandando 10 msg/s cada, esse overhead de 50 bytes é ~50 GB de bandwidth adicional por hora.

Para aplicações internas ou de baixa latência crítica, raw WebSocket. Para products que você vende para clientes corporativos que têm proxies proxy upon proxies, Socket.IO. A escolha não é técnica; é política.

A estratégia de estado distribuído: Redis como paliativo

Você não consegue escapar. Se vai escalar horizontalmente, precisa de sincronização de estado. Redis é o padrão industrial porque é rápido e simples.

Como funciona: o fluxo de mensagem distribuído

Digamos que você tem 3 servidores WebSocket. Client A conecta em Server 1. Client B conecta em Server 2. A quer enviar mensagem para B.

  1. Server 1 recebe mensagem de A
  2. Server 1 publica em canal Redis: “chat:room:123” → “{de: A, para: B, msg: …}”
  3. Server 2 é subscriber desse canal (porque B está lá)
  4. Server 2 recebe do Redis e entrega para B via WebSocket

Parece trivial? Não é. Há bodes na sala:

Problema 1: Message Ordering Se A manda 3 mensagens rapidinho (1ms de intervalo), e elas vão para servidores diferentes via Redis, B pode receber em ordem errada. Redis Streams resolve isso, mas adiciona complexidade.

Problema 2: Dead Connections Server 1 publica mensagem de A para B, mas B desconectou. A mensagem fica no Redis até expirar. Se B está offline por 30 minutos, você perde 30 minutos de buffer? Precisa banco de dados. Agora você tem Redis + PostgreSQL + complexidade.

Problema 3: Redis Becomes a Bottleneck Em escala, o Redis é o gargalo. Você precisa de Redis Cluster. Você aumenta latência ainda mais porque sharding complica roteamento. Cada mensagem precisa descobrir em qual shard está o recipiente.

Custo operacional de Redis em escala

Um Redis de 16 GB (cache bem robusto) custa ~$500-800/mês na AWS. Você precisará de replicação para alta disponibilidade (~$1.500-2.000/mês). Se sua aplicação se torna dependente de Redis para tudo (estado de conexões, cache de mensagens, sessions), você investe em monitoring, failover automático, backups. Isso adiciona $300-500/mês.

Seu custo total mensal sai de ~$3.500 (10 servidores + Redis) para ~$5.000+ rapidamente. E você ainda não está 100% seguro de que não vai perder dados em failover.

O custo oculto: monitoramento e observabilidade

Ninguém fala sobre isto. Uma aplicação HTTP simples, você monitora requisições e respostas. Pronto. Terminou.

WebSockets? Você precisa monitorar:

  • Conexões ativas por servidor (creep de memória é invisível)
  • Taxa de reconexão (indica problemas de rede)
  • Latência de mensagem end-to-end (Redis latency vs. rede vs. cliente)
  • Message queue depth (quantas mensagens estão esperando para enviar)
  • Dead connection accumulation (zumbis em RAM)
  • Redis memory usage e hit rates
  • CPU por evento de broadcast (cada notificação para 10k users custa quanto?)

Ferramentas como Prometheus + Grafana + alerting? ~$200-400/mês. Datadog? ~$1.000/mês. Você precisa, senão fica no escuro até cair.

Arquitetura recomendada: Hybrid Approach

Depois de dissectar os problemas, aqui está uma arquitetura que funciona em produção:

  1. 3-5 nós WebSocket em Go (ou Rust se tiver budget), cada um suportando ~20-30k conexões
  2. Load balancer com sticky sessions (ELB com stickiness de 1 hora)
  3. Redis para pub/sub e sincronização (não para cache de estado, apenas broadcast)
  4. PostgreSQL com connection pool para persistência real (não para sessão, para dados)
  5. Monitoramento agressivo com alertas em limites de memória

Custo estimado: $4.000-5.000/mês em AWS. Escalável até ~500k usuários simultâneos.

Para 500 mil a 5 milhões de usuários

Aqui você precisa de repensar tudo. Você provavelmente precisa usar managed WebSocket services como AWS API Gateway WebSocket, que abstrai muito da complexidade. Ou escolhe EMQX/Kafka em infraestrutura dedicada.

A decisão final: quando WebSocket não é a resposta

Às vezes, a resposta para “como escalamos WebSockets” é: “não use WebSockets”.

Se sua aplicação é notificações ocasionais (e-commerce: “seu pedido saiu”), Server-Sent Events (SSE) é mais simples. SSE é HTTP, stateless, escalável. Sem problemas de conexões mortas. Sem estado a sincronizar.

Se sua aplicação é polling de dados (dashboard que atualiza a cada 10 segundos), HTTP com cache agressivo é mais barato. Você consegue servir com CDN.

WebSocket faz sentido quando: latência sub-100ms é crítico, comunicação é bidirecional contínua, e volume de mensagens justifica o overhead de manutenção.

Conclusão: o preço da persistência

WebSockets são poderosos. Também são perigosos se você não entender o custo real. Manter estado em RAM de um servidor quebra a promessa central da nuvem moderna: elasticidade. Você acaba com servidores sempre 70-80% carregados (porque não consegue distribuir carga uniformemente) pagando por ociosidade.

A verdade que poucos querem ouvir: se você realmente precisa de WebSockets em escala corporativa, você provavelmente precisa de um produto gerenciado ou arquitetura especial. Tentar fazer DIY com Node.js + Redis é viável até ~100k conexões simultâneas. Além disso, é engenharia pesada.

Escolha com os olhos abertos. Meça. Monitore. E se a memória começar a subir sem motivo aparente, comece a procurar zumbis.