WebSocket em Rust: guia com Axum e Tokio | Rust Brasil

Crie um servidor WebSocket em Rust com Axum e Tokio: conexões, broadcast, salas, heartbeat, autenticação e quando usar WebSocket, SSE ou polling.

Para tempo real em Rust em 2026, a combinação padrão é Axum com a feature ws sobre o Tokio: o upgrade do protocolo cabe em um handler, cada conexão vira uma task leve e o tokio::sync::broadcast resolve o envio para vários clientes. Use WebSocket quando o cliente também envia mensagens (chat, colaboração, jogos); use SSE quando o fluxo é quase todo servidor→cliente; use polling quando o dado muda raramente. Este guia mostra o servidor mínimo, broadcast entre conexões, salas, heartbeat e o checklist de produção — montado sobre a mesma stack do guia de API REST com Axum e do Tokio.

Resposta rápida: WebSocket, SSE ou polling?

SituaçãoEscolhaPor quê
Chat, notificação bidirecional, jogo multiplayerWebSocketcanal full-duplex persistente, latência mínima
Feed, ticker, progresso de job, dashboardSSEHTTP simples, reconexão automática, cache e proxy amigáveis
Dado que muda a cada minutos/horaspollingsem estado no servidor, zero complexidade extra
Cliente envia pouco, servidor empurra muitoSSE (ou WebSocket com tower-http)SSE evita infraestrutura de upgrade
Atravessar ambientes hostis a upgradeSSEproxies corporativos ainda bloqueam WebSocket às vezes
Protobuf/binary em alta frequênciaWebSocket com Message::Binarysem overhead de texto por frame

A regra prática: não abra WebSocket para economizar requisições que não existem. Cada conexão persistente ocupa task, memória e atenção operacional. Abra quando a bidirecionalidade e a latência realmente pagarem a conta.

Servidor WebSocket mínimo com Axum

O Cargo.toml com as dependências essenciais:

[dependencies]
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
futures-util = "0.3"

O servidor echo completo cabe em poucas linhas — o Axum cuida do handshake HTTP e entrega um WebSocket pronto:

use axum::{
    extract::ws::{Message, WebSocket, WebSocketUpgrade},
    response::Response,
    routing::get,
    Router,
};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/ws", get(ws_handler));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn ws_handler(ws: WebSocketUpgrade) -> Response {
    ws.on_upgrade(handle_socket)
}

async fn handle_socket(mut socket: WebSocket) {
    while let Some(Ok(msg)) = socket.recv().await {
        if let Message::Text(text) = msg {
            if socket.send(Message::Text(text)).await.is_err() {
                break;
            }
        }
    }
}

socket.recv() devolve Option<Result<Message, _>>: None encerrou a conexão, Err indicou falha de protocolo. Em ambos os casos, sair do loop encerra a task e libera os recursos — o Tokio faz o resto.

Broadcast: enviando para todos os clientes

Um servidor real mantém dezenas ou milhares de conexões que precisam receber as mesmas mensagens. O tokio::sync::broadcast é a ferramenta natural: cada conexão faz subscribe() e roda uma task dedicada de envio.

use axum::{extract::ws::{Message, WebSocket, WebSocketUpgrade}, response::Response, extract::State};
use futures_util::{SinkExt, StreamExt};
use std::sync::Arc;
use tokio::sync::broadcast;

#[derive(Clone)]
struct AppState {
    tx: broadcast::Sender<String>,
}

async fn ws_handler(State(state): State<AppState>, ws: WebSocketUpgrade) -> Response {
    ws.on_upgrade(move |socket| handle_socket(socket, state))
}

async fn handle_socket(socket: WebSocket, state: AppState) {
    let (mut sender, mut receiver) = socket.split();
    let mut rx = state.tx.subscribe();

    let mut send_task = tokio::spawn(async move {
        while let Ok(msg) = rx.recv().await {
            if sender.send(Message::Text(msg.into())).await.is_err() {
                break; // cliente desconectou
            }
        }
    });

    let tx = state.tx.clone();
    let mut recv_task = tokio::spawn(async move {
        while let Some(Ok(Message::Text(text))) = receiver.next().await {
            // Ignora erro: sem assinantes, não há a quem enviar
            let _ = tx.send(text.to_string());
        }
    });

    // Se qualquer task termina, a conexão acabou: cancele a irmã
    tokio::select! {
        _ = &mut send_task => recv_task.abort(),
        _ = &mut recv_task => send_task.abort(),
    }
}

Dois detalhes que evitam sustos: o canal broadcast tem capacidade fixa — um cliente lento que não consumir o buffer recebe RecvError::Lagged e deve decidir entre sincronizar estado via REST ou reconectar. E select! garantindo o abort() da task irmã impede vazamento de tasks para conexões mortas.

Salas e canais separados

Para chat com salas, troque o Sender global por um mapa concorrente com um canal por sala:

use dashmap::DashMap;
use std::sync::Arc;

#[derive(Clone)]
struct Rooms {
    map: Arc<DashMap<String, broadcast::Sender<String>>>,
}

impl Rooms {
    fn join(&self, room: &str) -> broadcast::Receiver<String> {
        self.map
            .entry(room.to_string())
            .or_insert_with(|| {
                let (tx, _rx) = broadcast::channel(64);
                tx
            })
            .subscribe()
    }
}

Crie a rota como /ws/{room} (sintaxe de parâmetro do Axum 0.8), extraia o nome com Path<String> e chame rooms.join(&room) no upgrade. Quando o último assinante de uma sala sai, remova o Sender do mapa para não acumular canais órfãos.

Heartbeat: pings que mantêm a conexão viva

Proxies, load balancers e plataformas de PaaS fecham conexões ociosas — os timeouts comuns variam de 30 a 120 segundos. O servidor precisa pingar antes disso:

use std::time::Duration;

async fn pump(socket: WebSocket) {
    let (mut sender, mut receiver) = socket.split();
    let mut tick = tokio::time::interval(Duration::from_secs(25));

    loop {
        tokio::select! {
            _ = tick.tick() => {
                if sender.send(Message::Ping(vec![].into())).await.is_err() {
                    break;
                }
            }
            msg = receiver.next() => match msg {
                Some(Ok(_)) => {} // qualquer quadro renova a conexão
                _ => break,
            },
        }
    }
}

Navegadores respondem pings automaticamente no nível do protocolo — não é preciso tratar Message::Pong à mão. Clientes nativos, porém, podem exigir que sua aplicação responda. Também vale um tokio::time::timeout envolvendo o recebimento: cliente vivo responde, cliente morto estoura o timeout e libera a task.

Autenticação e o checklist de produção

O navegador não permite definir cabeçalhos de autenticação no handshake do WebSocket. As saídas práticas:

  1. Token curto na query string, validado antes do on_upgrade, com expiração de minutos — o padrão mais comum;
  2. Cookie de sessão enviado automaticamente pelo navegador no handshake;
  3. Token de uso único via REST: o cliente pede um ticket em /ws-ticket autenticado por JWT e troca pela conexão.

Com autenticação resolvida, o checklist que separa uma demo de um serviço pronto:

  • Valide antes do upgrade — negar após a conexão abre custe a reconexão em loop;
  • Limite conexões por usuário/IP — sem limite, um cliente abre milhares de sockets;
  • Limite o tamanho de mensagens — payloads gigantes em texto derrubam memória;
  • Broadcast com buffer dimensionado — e política explícita para Lagged;
  • Heartbeat + timeout de leitura — para colher conexões mortas;
  • Shutdown gracioso — trate SIGTERM com axum::serve(...).with_graceful_shutdown, fechando canais e deixando clientes reconectarem;
  • Métricas por estado da conexão — conexões ativas, mensagens por segundo e desconexões por motivo, no formato do guia de Prometheus com Axum.

Erros comuns

Bloquear a task do socket

Chamar código bloqueante (std::thread::sleep, I/O síncrono, bcrypt) dentro do handler congela toda a conexão. Use tokio::task::spawn_blocking para trabalho pesado e mantenha o loop do socket enxuto.

Estado global mutável sem concorrência pensada

Um Arc<Mutex<HashMap<…>>> disputado por todas as conexões vira gargalo. Prefira broadcast, watch ou DashMap por sala — cada padrão existe para um formato de fan-out.

Ignorar Lagged do broadcast

Cliente lento perdeu mensagens e o canal não reenvia. Trate o erro sincronizando o estado atual via REST em vez de fingir que nada aconteceu.

Um canal broadcast gigante para tudo

Canal com capacidade alta esconde o problema de backpressure e aumenta a memória por assinante. Dimensione pelo ritmo real de mensagens da sala.

Reconexão sem backoff no cliente

Servidor reiniciado + clientes reconectando em loop = tempestade de handshakes. Exponential backoff com jitter é obrigatório no cliente de produção.

Esquecer que WebSocket é estado

Uma API REST escala adicionando réplicas; um cluster WebSocket precisa de stickiness no balanceador ou de um barramento entre instâncias (Redis pub/sub, NATS). Decida isso antes do segundo replica.

WebSocket como sinal de carreira

Tempo real é um dos projetos que mais aparecem em entrevistas de backend Rust no Brasil: cobre tarefas assíncronas, canais, concorrência, timeout e operação sob carga — exatamente o que times de fintechs e empresas que usam Rust avaliam em vagas sênior. Um chat com salas, autenticação, heartbeat e métricas publicados no seu portfólio vale mais que dez CRUDs. Para as perguntas clássicas de entrevista, revise o guia de perguntas e respostas.

Perguntas frequentes

Como criar um WebSocket em Rust?

Use o Axum com a feature ws sobre o runtime Tokio. Registre uma rota GET com WebSocketUpgrade, chame on_upgrade para receber o socket e processe Message::Text e Message::Binary em um loop assíncrono. O Axum delega o protocolo ao crate ws, enquanto o Tokio cuida das tasks de envio e recebimento.

WebSocket ou SSE: qual escolher?

Escolha WebSocket quando o cliente também precisa enviar mensagens, como chat, jogos e edição colaborativa. Escolha Server-Sent Events quando o fluxo é predominantemente do servidor para o cliente, como feeds, notificações e dashboards, porque SSE é HTTP simples, reconecta sozinho e atravessa proxies com menos configuração. Se o cliente só busca dados de tempos em tempos, polling simples basta.

Como autenticar uma conexão WebSocket no navegador?

O navegador não permite definir cabeçalhos Authorization no handshake do WebSocket. As opções práticas são validar um token curto na query string durante o upgrade, depender do cookie de sessão existente, ou emitir um token de uso único via REST e trocá-lo pela conexão WebSocket. Nunca aceite token de longa duração na URL sem expiração curta e rotação.

Como enviar mensagens para vários clientes em Rust?

Use um tokio::sync::broadcast. Cada conexão faz subscribe e mantém uma task de envio consumindo o canal, enquanto uma task de recebimento publica as mensagens recebidas. Para salas ou canais separados, guarde um Sender por sala em um mapa concorrente como DashMap e faça subscribe no momento do upgrade.

Por que minha conexão WebSocket cai atrás de load balancer?

Porque proxies e balanceadores fecham conexões ociosas, normalmente entre 30 e 120 segundos. Envie pings periódicos do servidor com Message::Ping em um tokio::time::interval, trate a resposta como sinal de vida, defina timeout de leitura e verifique o limite de idle timeout do seu proxy ou provedor.

Como testar WebSocket em Rust?

Em testes de integração, suba a aplicação com Tokio e conecte um cliente como tokio-tungstenite, validando handshake, troca de mensagens, desconexão e broadcast entre dois clientes. Em unit tests, teste handlers e regras de sala com traits e fakes, sem depender de rede real.

Conclusão

WebSocket em Rust deixa de ser assustador quando você enxerga o modelo: um handler faz o upgrade, cada conexão vira duas tasks pequenas ligadas por canais, e a produção se resolve com heartbeat, limites e shutdown gracioso. Axum e Tokio cobrem todo o caminho — do echo de 20 linhas ao chat com salas atrás de load balancer. Continue com o guia do Axum, tower-http em produção, background jobs com Tokio e o tutorial de API REST. Para aplicar tempo real no mercado, acompanhe as vagas Rust atualizadas diariamente.