1 milhão de conexões WebSocket: o teto do Python e o chão do Rust
Por @zejuniortdr em Jul 20, 2026

No primeiro post deste blog eu mostrei o Axum superando o FastAPI numa rota HTTP. A reação mais comum foi: “ok, mas a diferença em request/response não muda minha vida”. Justo. Então hoje eu vou para o terreno onde a diferença deixa de ser conforto e vira teto: conexões persistentes.
Chat, notificações em tempo real, dashboards ao vivo, feeds de cotação, presença online — tudo isso são WebSockets que ficam abertos. E o problema de manter conexões abertas é radicalmente diferente do problema de responder requisições. É aqui que o Python encontra uma parede e o Rust ainda está confortável.
O problema real
O custo de uma conexão WebSocket não está em processá-la — está em mantê-la viva enquanto ela não faz nada. Cada conexão ociosa custa memória. E o problema clássico de servidores, o “C10k” (10 mil conexões simultâneas), virou “C1M” — um milhão — em aplicações realtime modernas.
O gargalo aparece em duas frentes:
- Memória por conexão: cada conexão carrega buffers e estado. Multiplique por um milhão.
- Modelo de concorrência: segurar um milhão de conexões exige que “esperar” seja barato. Em Python, cada conexão no event loop carrega o overhead de uma coroutine pesada; em Rust, é uma
taskde poucos KB.
O servidor de eco em cada linguagem
Vamos comparar o exemplo mínimo e honesto: um echo server WebSocket.
Python — FastAPI + uvicorn
# app.py
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
@app.websocket("/ws")
async def ws(websocket: WebSocket):
await websocket.accept()
try:
while True:
msg = await websocket.receive_text()
await websocket.send_text(msg)
except WebSocketDisconnect:
pass
uvicorn app:app --host 0.0.0.0 --port 8000
Código limpo, idiomático, funciona lindamente — até a carga crescer.
Rust — Axum + tokio
use axum::{
extract::ws::{Message, WebSocket, WebSocketUpgrade},
response::Response,
routing::get,
Router,
};
#[tokio::main]
async fn main() {
let app = Router::new().route("/ws", get(handler));
let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn handler(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(eco)
}
async fn eco(mut socket: WebSocket) {
// Cada conexão é uma task tokio de poucos KB. Um milhão delas cabe na RAM.
while let Some(Ok(msg)) = socket.recv().await {
if let Message::Text(texto) = msg {
if socket.send(Message::Text(texto)).await.is_err() {
break;
}
}
}
}
A diferença não está na clareza do código — os dois são legíveis. Está no que acontece quando você abre a centésima milésima conexão.
O teste de carga
Subimos cada servidor numa máquina com 8GB de RAM e abrimos conexões WebSocket ociosas (apenas mantidas vivas com ping periódico), medindo até a degradação. Depois, com 50k conexões ativas, disparamos um broadcast e medimos a latência P99.
Conexões ociosas até saturar 8GB
| Servidor | Conexões antes de degradar | RAM por conexão (aprox.) |
|---|---|---|
| FastAPI + uvicorn | ~48.000 | ~46KB |
| Axum + tokio | ~1.000.000+ | ~2,4KB |
Latência de broadcast com 50k conexões ativas
| Servidor | P50 | P99 |
|---|---|---|
| FastAPI + uvicorn | 180ms | 1.240ms |
| Axum + tokio | 9ms | 38ms |
Os números variam com kernel, limites de file descriptor (ulimit -n), tamanho de buffer e o que cada conexão realmente faz. Mas a ordem de grandeza é estrutural: ~20x mais conexões por GB e latência de broadcast uma ordem de grandeza menor sob a mesma carga.
Importante: o FastAPI não está “quebrado”. Ele foi feito para um modelo onde conexões são curtas. WebSocket em altíssima escala simplesmente não é o jogo para o qual o stack Python foi otimizado.
Por que o Rust segura isso
- Tasks são baratíssimas. Uma task tokio ociosa custa poucos KB; uma coroutine Python esperando num event loop custa muito mais, e o interpretador soma overhead por cima.
- Sem GIL no caminho. O agendamento das conexões distribui por várias threads do SO sem disputa de lock global.
- Memória previsível. Sem garbage collector pausando o mundo, a latência de broadcast não tem picos misteriosos sob pressão de memória.
A arquitetura que eu recomendo de verdade
A lição não é “reescreva seu chat inteiro em Rust”. É colocar o Rust na borda de tempo real, onde a escala dói, e deixar o Python cuidar do que ele faz bem: a regra de negócio.
┌────────────────────┐
1M clientes ───► │ Gateway realtime │ ◄── Axum/tokio
(WebSocket) │ (Rust) │ segura as conexões
└─────────┬──────────┘
│ HTTP/eventos só quando precisa
┌─────────▼──────────┐
│ Django / FastAPI │ ◄── regra de negócio,
│ (Python) │ auth, persistência
└────────────────────┘
O gateway Rust mantém o milhão de conexões e faz fan-out de mensagens. O Python continua sendo o cérebro do produto, chamado só quando há trabalho real a fazer. Cada linguagem na função em que é imbatível.
Contraponto honesto
- Se a sua aplicação tem 500 conexões simultâneas, nada disso importa — fique no Python e seja feliz.
- O ecossistema Python de WebSocket (Django Channels, Socket.IO) entrega produtividade e integração que um gateway Rust caseiro não tem de graça.
- Operar um serviço Rust adicional é custo de complexidade real. Só vale quando a escala justifica.
O ponto não é que o Python é ruim. É que existe um teto — e quando você bate nele, jogar mais réplica Python no problema fica caro rápido, enquanto o chão do Rust ainda está muito abaixo dos seus pés.
Checklist para um gateway realtime em produção
- Ajuste limites do SO:
ulimit -n(file descriptors) e parâmetros detcpdo kernel. - Defina protocolo de heartbeat/ping para detectar conexões mortas sem custo alto.
- Faça backpressure: cliente lento não pode segurar memória do servidor indefinidamente.
- Meça memória por conexão com carga real, não com echo trivial.
- Tenha um plano de reconexão no cliente — em escala, conexões caem o tempo todo.
O que esse caso ensina
Throughput de request/response é uma conversa. Conexões persistentes em escala é outra completamente diferente — e é onde a arquitetura de concorrência do Rust deixa de ser “mais rápida” e passa a ser “possível”. O Python te leva longe; o Rust te leva até onde o Python não alcança.
No próximo e último post desta sequência, eu fecho com a ideia mais ambiciosa de todas: escrever uma regra de negócio uma única vez e rodá-la igual no navegador e no seu back-end Python. Mesma lógica, dois mundos.
Quer se aprofundar em Rust de forma prática, aplicada ao mundo real e com foco em performance? Conheça o livro em desbravandorust.com.br.
Comentários