Como Venci a Rinha de Backend 2025 com TypeScript, Node.js e Redis
Contexto
A Rinha de Backend 2025 foi um desafio de performance e resiliência em sistemas distribuídos, onde a meta era processar um volume massivo de pagamentos com o menor tempo de resposta e máxima confiabilidade, dentro de limites rígidos de CPU e memória. Para mais detalhes, recomendo ir diretamente no repositório oficial da rinha (aproveita para deixar uma star!)
Fui para o desafio com um objetivo claro: eu queria utilizar alguma linguagem fora do panteão do backend, como Go e Rust e que fosse preferencialmente interpretada. Minha teoria era que uma solução bem desenhada, talvez se saísse melhor que desempenho bruto.
Justamente por isso, a minha primeira submissão foi em Python, maaas acabei esbarrando com o p99 muito distante do ideal e, como minhas cartas já estavam acabando e eu já tinha validado a solução (sem multas), decidi migrar a solução para o JavaScript, que era a outra linguagem interpretada que eu tinha alguma familiaridade.
Arquitetura de Solução
Vamos começar pelos requisitos:

Como mostra na imagem acima, haviam dois gateways de pagamento: o default e o fallback. Cada requisição feita do backend para os gateways gera um custo, sendo o default mais barato que o fallback. Porém, ambos iriam sofrer instabilidade (queda total ou demora no processamento) durante a execução dos testes, então era papel do backend identificar qual o melhor gateway para processar o pagamento. Para isso, cada gateway tinha um endpoint de health check, que retornava se o gateway estava disponível e qual era o tempo de processamento máximo que ele estava fornecendo no momento. Acontece que esse endpoint possui um limite de uma chamada para cada cinco segundos, então precisaríamos consultá-lo com sabedoria.
O objetivo final, portanto, era processar o maior número de pagamentos, com o menor gasto possível.
Dito isto, vamos dar uma olhada na minha primeira ideia de solução:

Bem simples e direto, né? Minha ideia era criar duas instâncias da API (obrigatório pelas regras da Rinha) atrás de um Nginx (load balancer), criar uma fila usando Redis, que seria consumida por mais duas instâncias de workers, que por sua vez, iriam perguntar qual o melhor gateway para um outro serviço dedicado a consultar os endpoints de health check dos gateways e salvar isso também no Redis e, por fim, salvar o resultado do processamento no Postgres.
Essa seria uma solução bem interessante, não fosse o fato de termos uma limitação de 1.5 vCPU e 350MB de memória. Eu percebi, bem rapidamente, que pulverizar os recursos em oito instâncias diferentes era longe de ser o ideal (primeiro aprendizado importante). E olha que eu fiz a primeira versão usando Python! Então da pra você imaginar o desempenho da coisa toda.
Embora eu tenha tentado otimizar de várias formas o Postgres, ele continuava caindo quando a carga do teste era muito alta, o uso de memória explodia e tudo ia por água abaixo. Tentei diminuir o tamanho da pool de conexões para reduzir a carga, mas o p99 aumentava muito. Estou longe de ser um especialista em Postgres, mas o mais básico como usar índices e SQL direto, ao invés de ORMs eu usei.
Depois gastar algumas horas tentando diferentes abordagens, eu resolvi recorrer a comunidade e ver como meus colegas estavam lidando com esse problema. Eis a minha surpresa ao perceber que a grande maioria das submissões até o momento não estava usando banco de dados algum! Essa foi a minha primeira vez participando da rinha, mas foi nesse momento que eu percebi o porquê de se chamar “Rinha de Backend” pra começo de conversa: é um vale tudo!
Resolvi seguir a onda, removi o Postgres e consolidei tanto a fila, quando os dados no Redis. A melhoria foi visível, mas não tão grande quanto eu imaginava. O lado positivo é que agora o teste estava conseguindo terminar quando submetido a altas cargas. Claro, o p99 ainda estava alto, mas foi um começo.
E aqui, eu gostaria de fazer um adendo para o segundo grande aprendizado: Roma não foi construída em um dia. Primeiro faça funcionar do jeito mais simples e despretensioso possível, depois você vai apertando os parafusos, escovando os bits, testando e retestando cada modificação, observando o que funciona e o que não funciona. Isso é ouro pro seu aprendizado.
Vou consolidar um pouco a história para esse artigo não ter dez mil caracteres. Fast forward para o momento em que me encontrei testando onde o gargalo do p99 estava e, sem grandes surpresas, a quantidade de recursos que eu tinha disponível na API era muito baixa. Dado ao fato de eu ainda ter o número alto de instâncias, todas elas estavam com recursos bem escarços. Se eu aumentava a quantidade de recursos na API e diminuía nos workers, meu p99 caia, mas em compensação a quantidade de pagamentos processados também. Então, era como tentar equilibrar pratos demais para poucos braços.
A solução foi juntar API e os workers.

Ficou mais ou menos como na imagem acima. Consolidei a API e os workers dentro da mesma instância, rodando concorrentemente. E o Redis servindo como storage e fila. O resultado já foi bem mais interessante. Agora que a API e os workers (mesmo dividindo) tinham mais recursos disponíveis, senti uma melhora bem grande no p99, já ficando próximo dos 10ms.
Mas eu ainda não estava satisfeito com o resultado. Principalmente por não gostar da forma como o serviço de Health Check estava implementado. Eu tentei incorporar esse processo como parte do worker e salvar o resultado em memória para as requests seguintes, de repente colocar no redis, mas nada funcionou tão bem quanto ter um processo rodando de forma dedicada. No geral, senti que colocar esse processamento que envolve requests HTTP para um serviço externo dentro do worker, fazia com que a capacidade total de processamento de pagamentos caísse bastante. Ao invés disso, o worker só precisaria consultar o cache e buscar qual gateway ele deveria utilizar. Ponto.
Em resumo, após alguns testes, resolvi também rodar o serviço de Health Check de forma concorrente dentro das duas instâncias principais do backend, ao lado da API e dos workers.

Mas agora eu tinha um outro problema. Lembra que o endpoint de health check dos gateways possui um limite de requisição de uma request a cada cinco segundos? Então, quando eu movi o serviço do Health Check, que rodava de forma dedicada em um único container, para rodar de forma concorrente nas duas instâncias principais, eu comecei a receber vários 429 Too Many Requests, afinal agora cada uma estava chamando o mesmo endpoint. A solução foi implementar um mecanismo de eleição de um líder entre as duas instâncias e somente o líder é responsável por fazer a request ao gateway. Usei o Redis para fazer o controle dos estados (quem é o líder) e também para salvar o resultado da request, que por sua vez é consumido pelos workers durante o processamento do pagamento.
Agora sim! Com apenas quatro containers rodando, eu tinha uma flexibilidade muito maior para gerenciar os recursos que eu tinha disponível. E graças ao excelentíssimo Event Loop do Node.js, conseguimos rodar os três serviços de forma concorrente e com altíssimo desempenho.
Nesse ponto, eu já estava bem feliz com a solução. Rodando com o script dos testes preliminares, já estava abaixo dos 10ms, mas eu ainda estava incomodado com algumas coisas.
Pensa comigo. Quando o script de teste fazia um POST para realizar um pagamento, tudo o que a API fazia era chamar uma função que chamava o Redis adicionando os dados do pagamento numa fila. Sim, isso já era async e eu retornava de cara e não esperava o processamento terminar pra isso (de novo, coisas que abrimos mão durante uma rinha kk), então não tinha muito o que processar aqui. Isso fazia muito sentido quando os workers rodavam em um container separado, como lá na primeira solução, lembra? Ou seja, o processo da API estava indo lá no Redis adicionar os dados do pagamento e os workers estavam indo também lá no Redis buscar isso! Sendo que os dois estão rodando no mesmo processo!
O que eu fiz em seguida foi bem óbvio, removi essa necessidade de ter uma fila no Redis e implementei uma fila em memória. Tinha algumas filas prontas, mas resolvi implementar uma para exercitar um pouco esse conhecimento. Nada demais, um put, um get e é isso. Fiz com que o POST dos pagamentos fosse apenas um put na fila e os workers ficam apenas fazendo get nela. Worked like a charm!
Com isso, eu reduzi muito o meu p99. O processo de POST é quase irrisório de tão simples e não preciso mais ficar fazendo chamadas externas para o Redis, que mesmo sendo MUITO rápido, não é tão rápido quanto fazer um put e um get numa fila em memória. Claro, colocar a fila rodando localmente em memória é algo longe de ser recomendado. Se a instância da API cair, a fila é totalmente perdida! Sem falar, que corremos o risco de uma instância ser mais sobrecarregada que outra, uma vez que as filas são locais e estamos confiando totalmente no loadbalancer fazer um bom trabalho.
Um outro ponto de melhoria interessante, foi substituir o tipo de conexão com o Redis. Só pra gente alinhar, nesse momento, o Redis está nos servindo basicamente como um storage em memória. Uma vez que movi a fila para rodar localmente em memória, o Redis ficou responsável apenas por fornecer um cache para o health check e para guardar o resultado do processamento dos pagamentos, que é consultado através da API de summary, que retorna um sumário de todos os pagamentos realizados por gateway. É importantíssimo que esse sumário esteja correto e seja muito performático, caso contrário o nosso p99 iria para o espaço, ou pior ainda, gerar multas por inconsistências nos resultados.
Enquanto eu usava o Postgres, era muito simples esse GET, apenas fazia um select na tabela filtrando pelo from (data de início) e o to (data final). Fim. Agora, eu não conhecia nenhuma forma fácil de fazer um filtro no Redis. Inclusive, minha primeira solução era buscar tudo que tinha salvo no redis e processar isso do lado da API, filtrando o retorno para gerar o sumário. Funcionava, mas sentia que dava pra fazer melhor.
Após algumas pesquisas e conversas no ChatGPT, achei uma forma que talvez funcionasse. Basicamente, a ideia era usar um esquema de score, associado ao dado do pagamento. Usando o ZADD do Redis, podemos adicionar elementos num conjunto ordenado, que por sua vez pode ser consultado utilizando o score dos elementos como filtro. Trocando em miúdos, eu posso usar o timestamp do pagamento como score do dado do pagamento em si. Com isso, basta que no GET do summary, eu faça um ZRANGEBYSCORE passando os timestamps desejados como filtro e o Redis já retornará exatemente os dados que eu preciso. Claro, eu ainda preciso fazer um processamento mínimo para somar os dados e montar o sumário, mas é beeeem mais performático que retornar tudo e processar localmente.
Mas a minha saga com o Redis ainda não terminou ai. Mesmo fazendo tudo isso, definitivamente ele ainda era a parte “frágil” do conjunto. Principalmente porquê ainda era preciso fazer uma request para um outro container milhares de vezes. De volta a prancheta. Depois de pesquisar mais um pouco, descobri que o Redis permite que clientes se conectem ao server utilizando sockets do Linux. Me pareceu uma ideia bem interessante e fui testar. Nos meus testes eu senti uma ligeira melhoria, mas acredito que tenha feito uma grande diferença no resultado final, uma vez que comunicação via sockets promove uma menor latência, quando comparado ao HTTP.
Minha ideia era estender essa melhoria para a conexão entre o Nginx e a API, para que essa também usasse sockets, mas acabou que não deu tempo finalizar.
Alguns detalhes técnicos
Aqui vai um resumo de alguns detalhes técnicos que fui percebendo durante a implementação:
- JSON é lento. Sempre que possível, busque bibliotecas dedicadas ao processamento performático de JSONs, como orjson, por exemplo.
- Cuidado com operações bloqueantes em processamento concorrente. Você está há um time.sleep() de distância de destruir sua solução.
- Trocar o Axios pelo Undici (HTTP/1.1) para as requisições aos gateways foi como sair da água para o vinho. Em um cenário de altíssima concorrência, o ganho de desempenho foi nítido, o Undici lidou com a carga de forma muito mais eficiente.
- Aumentar a quantidade de workers rodando de forma concorrente, quase sempre irá fazer com que você tenha mais inconsistências. Isso se deve principalmente ao fato dos recursos serem muito limitado, então reduzir a quantidade máxima de concorrência, faz com que cada worker tenha tempo e recursos o suficiente para finalizar seu trabalho.
- Timeouts são traiçoeiros! Nas primeiras versões, eu usei um timeout bem baixo para as requests ao gateway. Isso melhorou bastante o p99, mas trouxe inconsistências quando o gateway demorava mais que o timeout configurado (e esse cenário não era coberto no script de teste preliminar!! cuidado!). Para contornar isso, adotei duas estratégias: um timeout maior (para evitar client-side timeout) e retries. A lógica de retry foi simples: se falhar, volta pra fila e segue o jogo. Isso funcionou bem para o contexto da rinha, principalmente porque o gateway nunca demorou mais do que meu timeout de 10s. Se tivesse demorado, eu teria problemas. Afinal, não tratei o caso de o dado ser processado no gateway enquanto eu o reprocessava (minha dívida técnica). No fim, foi (como sempre) uma decisão de trade-off: preferi confiar que esse cenário não ocorreria. Não queria usar um timeout de 30s e correr o risco de travar meus workers, e também não tinha tempo hábil para implementar uma lógica robusta de deduplicação.
Resultado
A solução ficou em primeiro lugar na Rinha de Backend 2025, alcançando uma pontuação total de R$ 1.949.876,38 e um p99 de 2.55ms, gerando um bônus de 17%. Foram processados 90.591 pagamentos, sendo 52.945 pelo default gateway e 37.646 pelo fallback.

Para ver o resultado em detalhes, novamente peço para visitar o repositório oficial da rinha.
Conclusão
Acho que foi tudo. Quis focar mais na trajetória, do que nos detalhes técnicos em si, para isso convido-os a visitarem o repositório da solução e darem uma olhada no código. Para qualquer dúvida mais específica, podem me mandar uma mensagem no linkedin. Obrigado pela leitura!
PS: Post original no meu blog: https://ricassiocosta.me/2025/08/como-venci-a-rinha-de-backend-2025/