Apuração em tempo real com Bun: o que a eleição ensina
No domingo, 25 de outubro, milhões de brasileiros passaram a noite atualizando a página da apuração do segundo turno até sair o resultado: Lula venceu quando os últimos votos foram contados, como mostrou a cobertura ao vivo da Reuters. Não vou falar de política aqui. Opinião sobre isso você já tem, e não vou ser eu a mudar. O que me interessa é outra coisa: o que acontece quando um país inteiro abre a mesma página ao mesmo tempo, e como você montaria um painel de apuração em tempo real com Bun sem ver o servidor cair no primeiro boletim.
Por que uma noite de eleição é um teste de carga de verdade
Quase todo sistema tem pico. O e-commerce tem a Black Friday. O app de ingresso tem a abertura de vendas. Só que a noite de eleição tem um formato bem específico.
O público é enorme e chega todo junto. Ninguém fica olhando um gráfico por educação: as pessoas ficam lá por ansiedade. E o dado muda a cada poucos segundos, mas é o mesmo dado para todo mundo.
Esse último ponto muda o jogo. Você não está servindo 1 milhão de páginas diferentes. Está servindo um único JSON, com poucos KB, para 1 milhão de pessoas.
Faz a conta do jeito ingênuo. Imagine 200 mil pessoas com o painel aberto, cada uma fazendo polling a cada 5 segundos. Dá 40 mil requisições por segundo. Se cada uma dessas requisições vai até o banco perguntar a mesma coisa, o problema não é o runtime: você construiu um ataque DDoS contra o seu próprio PostgreSQL.
Polling, SSE ou WebSocket: qual usar num painel ao vivo?
Antes de abrir o editor, vale escolher o transporte. Resumindo:
- Polling com cache: o cliente pergunta de tempos em tempos e a CDN responde quase sempre. É simples, aguenta muito tráfego e passa por qualquer proxy corporativo.
- SSE (Server-Sent Events): o servidor empurra atualizações por uma conexão HTTP aberta. É unidirecional, e é justamente disso que um placar precisa.
- WebSocket: conexão bidirecional e persistente. É ótimo quando o cliente também fala, como em chat, jogo ou colaboração.
Num painel de apuração o cliente não fala nada, só escuta. Então, sendo honesto, polling com cache na CDN resolve 90% dos casos e é o que eu colocaria em produção primeiro.
Mesmo assim vale conhecer o caminho do WebSocket, porque é aí que o Bun tem um recurso que pouca gente usa: pub/sub embutido no servidor.
Como fazer apuração em tempo real com Bun e pub/sub nativo
No Node, quem quer WebSocket com broadcast normalmente instala uma lib, guarda as conexões num Set e faz um loop para mandar a mensagem para cada uma. Funciona, mas o loop roda em JavaScript e cresce junto com o número de clientes.
No Bun isso já vem no Bun.serve. Você inscreve a conexão num tópico e publica no tópico. O fan-out acontece no lado nativo do runtime.
let ultimoSnapshot = JSON.stringify({ secoesApuradas: 0 })
const server = Bun.serve({
port: 3000,
fetch(req, server) {
if (server.upgrade(req)) return
return new Response('Use WebSocket', { status: 426 })
},
websocket: {
open(ws) {
ws.subscribe('apuracao')
ws.send(ultimoSnapshot)
},
message() {}
}
})
setInterval(async () => {
const parcial = await buscarParcial()
ultimoSnapshot = JSON.stringify(parcial)
server.publish('apuracao', ultimoSnapshot)
}, 2000)
Repara em três detalhes.
Primeiro: o banco é consultado uma vez a cada 2 segundos, não importa se tem 10 ou 100 mil pessoas conectadas. A consulta deixou de depender do número de usuários.
Segundo: quem entra no meio da noite recebe o último snapshot na hora, no open. Ninguém fica olhando uma tela vazia esperando o próximo ciclo.
Terceiro: são umas 20 linhas, sem nenhuma dependência. Esse é o argumento que mais me convence no Bun. Não é o benchmark do README. É eu conseguir ler o servidor inteiro sem rolar a tela.
O gargalo de um sistema ao vivo quase nunca é o runtime. É quantas vezes você pergunta a mesma coisa para o banco.
E se eu quiser só polling? O Bun ajuda também
Se você seguir meu conselho e começar pelo polling, o Bun continua útil. O segredo é deixar a CDN fazer o trabalho pesado com um cache curto:
Bun.serve({
port: 3000,
routes: {
'/api/parcial': () =>
new Response(ultimoSnapshot, {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=2, s-maxage=2, stale-while-revalidate=5'
}
})
}
})
Com s-maxage=2, a CDN segura a resposta por 2 segundos. Aquelas 40 mil requisições por segundo viram, na origem, algo perto de uma requisição a cada 2 segundos por ponto de presença. O stale-while-revalidate garante que ninguém espere enquanto a CDN busca a versão nova.
A rota só lê uma variável em memória, que um setInterval atualiza. O banco nem sabe que tem gente olhando.
O que costuma quebrar (e não é culpa do runtime)
Já vi painel ao vivo cair em projeto pequeno por motivos bem mais bobos que "o runtime é lento". Os clássicos:
- Reconexão em manada. O servidor reinicia e 50 mil clientes tentam reconectar no mesmo milissegundo. Coloque backoff com jitter no cliente: espere um tempo aleatório antes de tentar de novo.
- Payload gordo. Mandar o resultado de todos os municípios a cada 2 segundos quando a pessoa só olha o total nacional. Separe por tópico:
apuracao:nacional,apuracao:sp, e por aí vai. Osubscribedo Bun aceita quantos tópicos você quiser. - Uma instância só. O pub/sub do Bun publica para quem está conectado naquele processo. Se você roda 4 instâncias atrás de um load balancer, cada uma precisa receber o dado novo. Dá para fazer isso com cada instância consultando a fonte por conta própria, ou com um Redis pub/sub distribuindo o evento.
- Timeout de proxy. Alguns proxies derrubam conexão ociosa depois de 60 segundos. O Bun tem
idleTimeoute manda ping sozinho, mas confira o que tem na frente dele.
O item 3 pega muita gente. Pub/sub nativo resolve o fan-out dentro de uma máquina. Ele não é um broker distribuído, e tratar ele como se fosse é pedir para metade dos usuários ver um número desatualizado.
Vale trocar seu backend para Bun por causa disso?
Minha opinião: não troque nada por causa de uma noite de eleição. Agora, se você vai começar um serviço novo que é basicamente "empurrar o mesmo dado para muita gente", o Bun é uma escolha muito boa. O que pesa é ter WebSocket, pub/sub, roteamento e TypeScript sem nenhum npm install. É menos coisa para atualizar e menos coisa para dar errado às onze da noite de um domingo.
Agora, se o seu app em Node já aguenta o tranco com cache bem configurado, o ganho de migrar é pequeno perto do risco. Arquitetura vence runtime. Um Express atrás de uma CDN bem configurada aguenta mais que um Bun consultando o banco a cada requisição.
A lição que fica da apuração é simples: quando todo mundo quer a mesma resposta, calcule uma vez e distribua muitas. O resto é detalhe de implementação, e o Bun deixa esse detalhe bem curto.
Se quiser ver como eu aplico essas ideias em sistemas reais, dá uma olhada nos projetos que já entreguei.
Resumo para o LinkedIn
Meia-noite, um país inteiro apertando F5 na mesma página. E o servidor de pé. Uma noite de eleição não é só tráfego alto. É um milhão de pessoas pedindo o mesmo JSON de poucos KB ao mesmo tempo. Com polling ingênuo, 200 mil painéis abertos viram 40 mil requisições por segundo contra o seu próprio banco. O jeito certo é consultar a fonte uma vez a cada 2 segundos e distribuir para todo mundo. No Bun isso cabe em umas 20 linhas, com pub/sub nativo e zero dependências. Arquitetura vence runtime: quando todo mundo quer a mesma resposta, calcule uma vez e distribua muitas. Escrevi o passo a passo completo, com WebSocket, cache na CDN e as armadilhas que derrubam painel ao vivo. O link está nos comentários. #Bun #TypeScript #WebSocket #Performance #Backend