Blog

Quando a venda trava numa pergunta chata: de 11% a 100% de sucesso sob carga

12 de agosto de 2026 · Fabership

Um founder que vende para grandes empresas descreveu o momento exato em que o pitch dele morre, no r/startups, em 30 de julho de 2026:

"This is the exact moment move fast products die in enterprise: someone asks a boring compliance question and the whole pitch stalls. Worth having a one-page data flow diagram ready before the first demo, not after." (fonte: https://www.reddit.com/r/startups/comments/1va32qm/comment/p0nw7wf/)

A pergunta quase nunca é dramática. Onde os dados moram. Quem pode ler. O que acontece quando as quarenta pessoas do cliente entram na mesma manhã de segunda-feira. Perguntas chatas, até o segundo em que você percebe que a sua resposta honesta é dar de ombros.

Alguém no r/lovable descreveu o formato disso melhor do que eu descreveria:

"This is the scariest category of vibe-coded bug, because the app looks completely fine until someone pokes at it." (fonte: https://www.reddit.com/r/lovable/comments/1tqcw0c/comment/oojnzsn/)

Ele escrevia sobre checagem de autorização que mora na interface em vez do banco. A frase descreve carga igualmente bem. Seu app parece bem com três usuários, parece bem com dez, e escolhe a pior plateia possível para a primeira vez em que deixa de parecer bem.

Então em julho passado eu fui cutucar o nosso. Rodamos um SaaS de compliance em produção, construído do jeito que a maioria desses é construída: rápido, com muito código gerado, sob prazo. Eu queria um número que desse para falar em voz alta.

Com 25 usuários simultâneos, 11% das requisições tinham sucesso. Não era lentidão. Era falha.

O suspeito óbvio era o pool de conexões do banco. O diagnóstico estava errado, e como achamos o certo é a coisa mais útil que posso entregar a um founder rodando um app feito com IA em produção.

A prova do crime

No meio do teste de carga, uma coisa deixou de fazer sentido: com 40 usuários virtuais, até o /health dava timeout. Nosso health check é um endpoint assíncrono com zero I/O. Ele não toca em nada. Nem banco, nem disco, nem rede. Existe exatamente um jeito de um endpoint desses dar timeout, e é o próprio event loop estar travado. O servidor não estava sobrecarregado. Estava na fila, atrás de trabalho síncrono que alguém (nós) tinha jogado no caminho assíncrono.

Se você levar um truque de debugging deste artigo, leve este: faça teste de carga no endpoint que não faz nada. Se ele degradar, pare de ajustar o pool e vá caçar bloqueio.

Os 22 bloqueios

Percorremos cada caminho de requisição e achamos 22 pontos onde o app travava o próprio loop. Vieram em cinco famílias, e eu apostaria dinheiro que um código gerado tem pelo menos três delas:

  1. Chamadas de OCR (pytesseract) rodando inline nos handlers de requisição.
  2. Rasterização de PDF (PyMuPDF a 150dpi), pesada de CPU, inline.
  3. Chamadas de subprocess (pdftoppm) esperando de forma síncrona.
  4. Uploads para storage de objetos (boto3 para R2/S3), presos em rede, chamados sem thread.
  5. file.read() em uploads dentro de handlers assíncronos.

Cada um congelava o servidor inteiro, para todos os usuários, enquanto rodava. Uma pessoa subindo um PDF parava o login de todo mundo. O conserto não é exótico: trabalho bloqueante vai para o asyncio.to_thread, ou para uma fila de workers quando é pesado, e I/O síncrono nunca é chamado direto de um handler assíncrono.

Achar todos é a parte difícil, e é por isso que hoje isso é garantido por um teste. Um guard baseado em AST percorre as rotas e os serviços e quebra o CI se alguém reintroduzir I/O síncrono. Achar uma vez é faxina. Manter fora é engenharia.

O que mais a auditoria revelou

Destravar o loop expôs a camada de baixo, e isso é típico. Conserto vem em camadas.

Queries N+1 por todo lado. Uma tela fazia 42 queries onde precisava de 4. Outra fazia 100 para uma visão de 50 empresas onde precisava de 2. Um gerador de transmissão ao governo rodava uma query de checagem de duplicata por funcionário, por risco, por setor — cerca de dez mil queries numa empresa de porte médio. Hoje ele pré-carrega as chaves existentes num conjunto e confere em memória.

O banco nunca tinha sido analisado. last_analyze era NULL nas 176 tabelas. Um ANALYZE e um índice composto depois, o planner acordou.

O dimensionamento do pool saiu de uma fórmula em vez de um palpite: 2 × workers × (pool + overflow) ≤ ~90% do max_connections. Contra a intuição, a jogada certa foi dimensionar o pool para baixo, de um teto bruto de 30 conexões para 18. Isso não é mais capacidade. É um modo de falha melhor: uma requisição que não consegue conexão agora falha em cinco segundos com um 503 e um Retry-After, em vez de ficar pendurada trinta e arrastar os workers junto.

Depois, dois bugs de integridade que só a carga tornou visíveis. Um middleware engolia exceções e rodava a requisição duas vezes em silêncio, gravando duplicatas. Uma importação descartava linhas válidas enquanto as reportava como importadas. Teste de carga é como bug de corretude é pego antes de os seus clientes pegarem por você.

Os números

AntesDepois
Sucesso sob carga11% com 25 usuários simultâneos100% com 26
Requisições atendidas no teste874, zero timeouts
Latência p50297 ms
Tela mais pesada42 queries4
Tabelas já analisadas0 de 176176 de 176

A suíte que mantém isso consertado: 20 testes de orçamento de query que falham se a contagem de queries de alguma tela crescer com N, mais os guards de AST no CI.

Vou dizer a parte sem glamour em voz alta, já que este é um artigo de engenharia e não um folheto. A cauda ainda é a frente aberta aqui. A latência mediana está boa; as requisições mais lentas são para onde vai a próxima rodada de trabalho.

Por que o código gerado parecia bom

Geradores de código escrevem código assíncrono plausível com minas síncronas dentro, porque o dado de treino tem os dois estilos colados e nenhum compilador reclama da mistura. O gerador não inventou esse padrão. Ele aprendeu com a gente.

Isso importa para como você lê o seu próprio código. O problema não é que uma máquina escreveu. O problema é que nada no ciclo entre "roda" e "vai pro ar" estava checando essa classe específica de erro, e nenhuma interface vai revelar isso para você. O app roda. A demo roda. Os dez primeiros usuários rodam. Ele cai exatamente no momento em que começa a contar.

De volta à pergunta chata

Aqui está o que o teste de carga me comprou, e não foi paz de espírito.

Comprou uma frase com um número dentro. Quando um comprador pergunta o que acontece em uso simultâneo, a resposta é "fazemos teste de carga antes de cada release; a última rodada aguentou 26 usuários simultâneos com 100% de sucesso em 874 requisições, zero timeouts, com mediana de 297 ms". Se esse número é suficiente depende de quantas pessoas o comprador pretende colocar ali, o que é uma conversa, e conversa é do que uma venda travada precisa.

Você não diz essa frase de memória nem de confiança. Ela vem de uma medição ou ela não existe.

Faça o diagnóstico você mesmo

Mantemos um autodiagnóstico gratuito de prontidão para produção com 13 pontos em fabership.com.br/self-check. Você roda no seu próprio sistema; eu não toco no sistema de ninguém, e isso aqui é regra, não parágrafo de política.

O ponto 11 é este artigo inteiro comprimido numa pergunta só: você sabe, por medição, quantos usuários simultâneos o seu app aguenta?

Lucas Augusto é founder da Fabership, estúdio de engenharia que leva SaaS gerado por IA até produção.

Descubra antes do teu maior cliente descobrir.

Uma conversa pra dimensionar. Se não formos o time certo, você ouve isso na call, de graça, não depois da fatura.

Agendar uma conversa