Tem um tipo de dor de desenvolvimento que você aprende a ignorar porque “é assim que funciona”. O servidor demora 15 segundos pra subir. Você faz uma mudança, espera dois minutos para ver o resultado. Você instala um pacote e ele quebra porque o autor usou ESM e o seu bundler esperava CJS.
Foi assim por um bom tempo aqui na AbacatePay. Até a gente decidir que não precisava mais ser.
O problema com Node + Fastify
Nossa API rodava em Node com Fastify. Para quem não conhece, Fastify é um framework excelente — rápido, bem mantido, com um ecossistema sólido de plugins. O problema não era o Fastify em si. Era a soma de tudo.
O servidor demorava de 10 a 15 segundos para subir. Parte disso era o nosso processo de vault: na inicialização, o app autenticava com nosso Vault, buscava todos os secrets do ambiente conforme eram requisitados até o servidor subir por completo. Com TypeScript por cima e o autoload de plugins do Fastify, o tempo se acumulava.
O hot reload levava de um a dois minutos. Depois de uma mudança, você esperava. E esperava. E quando tinha que testar algo rápido, esse tempo destruía o raciocínio.
ESM era uma bagunça constante. O exemplo mais emblemático: tivemos que vendar o nanoid. Literalmente copiar o código-fonte da biblioteca para dentro do repositório porque o Fastify não conseguia lidar com os exports ESM/UMD do pacote. O arquivo começava com // @ts-nocheck e // taken from https://github.com/ai/nanoid. Era funcional, mas era um sinal claro de que estávamos brigando com a ferramenta.
A estrutura de plugins tinha um problema silencioso. A autenticação era um hook global — um preHandler que interceptava todas as requisições e verificava manualmente o prefixo da URL:
fastify.addHook("preHandler", async (request, reply) => {
if (request.url.startsWith("/v1/")) {
// validar auth...
}
if (request.url.startsWith("/v1/public-mrr/")) {
// outra lógica de auth...
}
});Toda nova rota com uma regra de auth diferente virava mais um if no mesmo arquivo. O escopo estava espalhado.
Por que Bun
Bun não é só um runtime mais rápido. É uma plataforma diferente.
TypeScript nativo. Sem ts-node, sem tsx, sem configurar tsconfig para scripts avulsos. bun run scripts/alguma-coisa.ts funciona. Ponto. Para uma equipe que escreve TypeScript em todo lugar — APIs, workers, scripts de manutenção, automações internas — isso elimina uma classe inteira de atrito.
Startup em 1 segundo. O mesmo processo de buscar secrets no vault que levava 10–15 segundos agora leva cerca de 1 segundo. Bun resolve o código JavaScript muito mais rápido que o V8 no cold start, e o overhead do TypeScript some.
Hot reload quase instantâneo. Uma mudança no código reflete imediatamente. Isso não parece grande coisa até você voltar a trabalhar na codebase que levava dois minutos pro hot reload e perceber que ganhou mais alguns fios de cabelos e uns anos de vida.
ESM e CJS simplesmente funcionam. O nanoid voltou a ser uma dependência normal em package.json. Sem copiar código-fonte, sem // @ts-nocheck, sem brigar com resolução de módulos. Bun lida com ambos os formatos transparentemente.
Monorepo sem cerimônia. Com workspaces nativos no package.json, conseguimos dividir o que era um monólito em src/ em serviços independentes, com código compartilhado entre eles. O que antes era uma pasta gigante virou isso:
abkt-monorepo/
├── services/
│ ├── api/
│ ├── dashboard-api/
│ ├── dashboard-frontend/
│ ├── workers/
│ ├── consumers/
│ └── ...
├── shared/
│ ├── logger/
│ ├── redis-cache/
│ ├── types/
│ └── ...
├── modules/
│ ├── billing/
│ ├── customer/
│ ├── subscription/
│ └── ...
...Cada serviço tem seu próprio package.json e importa de shared/* e modules/* via workspace. Bun gerencia tudo sem configuração extra.
Redis nativo, compilação nativa, scripts nativos. O ecossistema do Bun cresce rápido. Hoje usamos bindings nativos de Redis, compilamos binaries quando faz sentido, e criamos ferramentas internas com um único arquivo TypeScript. Com Node, cada uma dessas coisas exigia uma decisão de setup separada.
Por que Elysia
A escolha do framework foi mais sobre como ele compõe do que sobre throughput.
Com Fastify, a forma de compartilhar estado entre plugins era decorar a instância principal. Funcionava, mas criava acoplamento implícito. Com Elysia, cada plugin recebe o contexto que ele precisa via encadeamento — você compõe plugins em vez de decorar um objeto global.
Auth por escopo, não por URL prefix. Em vez de um preHandler global com if (url.startsWith('/v1/')), cada controller aplica o middleware de auth que faz sentido pra ele:
const app = new Elysia()
.use(appController) // tem appAuth aplicado internamente
.use(apiController) // tem apiAuth aplicado internamente
.use(internalController); // sem auth externoCada controller é isolado. A auth não vaza de um contexto para o outro.
Eden Treaty — type safety de ponta a ponta. Essa é a feature que mais mudou o dia a dia do frontend. Elysia exporta todos os contratos da sua API em forma de types. O cliente importa esse tipo e recebe autocomplete e validação em tempo de compilação para todas as chamadas:
// server
export type AppBackend = typeof app;
// client
import { treaty } from "@elysia/eden";
import type { AppBackend } from "../app-backend";
const client = treaty<AppBackend>("localhost:5566");
// TypeScript sabe exatamente o que essa rota aceita e o que retorna
const { data, error } = await client.app.products.list.get();Sem gerar código a partir de OpenAPI. Sem schemas duplicados. Sem any espalhado no frontend. Sem erro de compilação do TypeScript. O contrato entre servidor e cliente é o próprio tipo do servidor.
TypeScript escalável. À medida que o projeto crescia, o checker do TypeScript ficava mais lento com Node. Com Bun, o custo de ter mais tipos simplesmente não aparece da mesma forma. O fato do Bun poder rodar TypeScript sem compilação, foi um divisor de águas.
O que deu errado no caminho
Não foi uma migração sem atrito.
O maior obstáculo foi parar de pensar em termos de Fastify. O modelo mental de “plugin que decora a instância” está tão internalizado depois de anos que os primeiros dias com Elysia foram de constante ajuste de expectativa.
O Elysia tem uma quirk importante que aprendemos da forma difícil: você precisa criar uma nova instância para cada guard ou controller. Plugins Elysia compartilham estado pela cadeia de herança da instância. Se você reutiliza a mesma instância em contextos diferentes, eles contaminam um ao outro.
Tivemos um incidente em staging onde tenants diferentes estavam recebendo dados um do outro no dashboard. O motivo: o plugin de auth estava sendo aplicado de uma forma que não respeitava o isolamento de instância do Elysia. A sessão de um usuário estava vazando para a requisição de outro porque ambos compartilhavam o mesmo contexto de instância. Pegamos antes de chegar em produção, mas o susto foi suficiente para levar a sério.
A correção foi simples — seguir estritamente a documentação do Elysia sobre escopo de instâncias. Mas o diagnóstico levou tempo porque o comportamento era sutil. Hoje isso está documentado internamente e é o primeiro item do checklist de code review em qualquer novo controller.
Fora isso, a migração foi surpreendentemente suave. O Elysia tem boa documentação, o ecossistema de plugins cobre o que precisa, e o modelo de composição — uma vez que você internaliza — é mais limpo do que o modelo de plugins do Fastify.
O resultado
Dev server de 15 segundos para 1 segundo. Hot reload de dois minutos para instantâneo. Um monorepo real sem overhead de configuração. TypeScript que roda em qualquer lugar sem setup. Contratos de API com type safety completo entre servidor e cliente. E zero código vendorizado por causa de incompatibilidade de módulos.
Não foi uma decisão de hype. Foi uma decisão de DX — e a diferença no dia a dia de desenvolvimento é visível.
Precisa de ajuda? ajuda@abacatepay.com
Construiu algo legal? Marca a gente. @abacatepay em todas as redes.