Quando (não) usar reativo — custo cognitivo, debugging e stack traces
TL;DR
O reativo cobra um custo cognitivo real: stack traces fragmentados (a thread que estoura não é a que montou a cadeia), debugging sem step num pipeline lazy/assíncrono, contágio (uma única lib bloqueante no meio anula o ganho) e a curva de aprendizado da equipe. Ele só vale quando o ganho é concreto — streaming real, backpressure de verdade, ou um stack que já é reativo de ponta a ponta. Para CRUD comum com Java 21+, Virtual Threads + Spring MVC é o default mais barato. A própria doc do Spring é direta: “If you have a Spring MVC application that works fine, there is no need to change.”
O que é
Esta é a nota de honestidade total do galho. As notas anteriores ensinaram o como: Mono/Flux, operadores, Schedulers, backpressure, WebFlux. Esta pergunta o quando não — porque “reativo é não-bloqueante, logo é mais rápido” é uma meia-verdade que custa caro quando aplicada sem critério.
A tese: o reativo é uma ferramenta de troca, não um upgrade. Você troca um modelo imperativo simples (uma thread por requisição, stack trace linear, step debugger funcionando) por um modelo declarativo eficiente em I/O, mas com um custo cognitivo que se paga em horas de debugging e em onboarding. Quando o ganho de eficiência é real e grande, a troca compensa. Quando não é, você importou toda a complexidade sem o benefício.
É importante separar essa crítica do que ela não é. Não é dizer que reativo é ruim, ou que Reactor é mal projetado, ou que ninguém deveria usar — é uma tecnologia excelente para os problemas que ela resolve. A crítica é de adequação: usar a ferramenta certa no lugar errado degrada qualquer projeto, e o reativo é particularmente caro de aplicar mal porque seus custos (debugging, onboarding) são contínuos e difíceis de reverter depois que o sistema cresceu. A nota não pede que você abandone o reativo; pede que você o escolha conscientemente, com um requisito que o justifique, em vez de adotá-lo por inércia ou moda.
Por que importa
Em entrevista sênior, defender reativo sem reconhecer seus custos sinaliza imaturidade — soa hype-driven. O sinal de senioridade é o oposto: saber recusar a ferramenta da moda quando ela não cabe no problema.
E o contexto mudou. Antes do Java 21, reativo era quase a única forma de escalar I/O sem estourar threads de plataforma. Hoje, Virtual Threads entregam alta concorrência de I/O mantendo o código imperativo — stack traces inteiros, step debugging, try/catch normal. Isso desloca o ponto de equilíbrio: a faixa de problemas onde “reativo é a única saída” encolheu bastante. Saber onde ela ainda existe (e onde não existe mais) é o cerne desta nota. Veja o confronto detalhado em Reativo vs Virtual Threads.
Vale separar os dois eixos que o reativo combina e o hype confunde. Eixo um: escalabilidade de I/O — atender muita concorrência sem uma thread de plataforma travada por requisição. Eixo dois: semântica de fluxo — backpressure, composição de streams, operadores sobre dados contínuos. Antes do Loom, o eixo um arrastava o eixo dois: você adotava todo o modelo reativo só para ganhar a escalabilidade. Virtual Threads quebram esse acoplamento — agora você consegue a escalabilidade de I/O sem assinar a semântica de fluxo. Por isso a pergunta certa virou: “eu preciso da semântica de fluxo?“. Se a resposta é não — e para CRUD ela quase sempre é —, o reativo perdeu o argumento que o justificava. Restou só o que dá backpressure e streaming de verdade.
Como funciona
Stack traces fragmentados: a thread que estoura não é a que montou
No imperativo, o stack trace é uma narrativa: cada linha conta quem chamou quem, do main até o ponto da exceção. No reativo, a montagem (assembly — onde você declara flux.map(...).flatMap(...)) e a execução (onde o erro de fato acontece, possivelmente em outra thread via Schedulers) são momentos e threads diferentes. Resultado: o stack trace mostra as engrenagens internas do Reactor e o ponto de subscription — que a própria doc descreve como o ponto “less interesting” — mas não a linha da sua cadeia onde o erro nasceu.
O Reactor oferece duas recuperações, ambas com custo:
Hooks.onOperatorDebug()— modo de debug global que captura o stack trace de montagem de todo operador. A doc é explícita sobre o preço: “creating a stack trace is costly. That is why this debugging feature should only be activated in a controlled manner, as a last resort.” É a forma “mais fácil, mas também a mais lenta” — proibido em produção.checkpoint()— instrumenta pontos específicos da cadeia. Custo localizado em vez de global; a variantecheckpoint(String)“impõe menos custo de processamento que umcheckpointregular”. É o que você usa quando já sabe qual cadeia precisa de rastreabilidade.
O ponto cognitivo: no imperativo, o contexto de erro é grátis e sempre completo. No reativo, é um recurso que você paga (em overhead) e planeja (onde colocar os checkpoint).
Há uma assimetria perversa aqui. O modo barato (checkpoint) só ajuda se você já tinha previsto que aquele trecho daria problema — ou seja, antes do bug existir. O modo que ajuda em qualquer lugar (Hooks.onOperatorDebug()) é caro demais para ficar ligado. Então, no momento exato em que você mais precisa de contexto — um erro inesperado em produção — você normalmente tem o pior dos dois: sem checkpoint naquele ponto e sem o hook global ligado. A doc sugere o meio-termo ReactorDebugAgent (instrumentação por bytecode, custo só na inicialização), mas mesmo ele é uma dependência extra e uma decisão consciente — nada disso é o default gratuito do imperativo.
Debugging sem step: o pipeline é lazy e assíncrono
Um breakpoint dentro de um .map(x -> ...) dispara no momento da montagem da cadeia, não quando o dado flui — porque nada acontece até o subscribe. Dar step over não percorre o fluxo de dados; ele percorre a construção da cadeia. Quando o dado finalmente passa, ele passa potencialmente em outra thread, fora do alcance do seu step.
Na prática isso significa: o ciclo “breakpoint → step → inspeciona variável” — o pão com manteiga do debugging imperativo — não funciona num pipeline reativo. Você troca step debugging por log(), doOnNext(), doOnError() e checkpoint(). É observabilidade por instrumentação, não por inspeção interativa. Funciona, mas é uma habilidade diferente que a equipe precisa adquirir.
Some um detalhe que parece menor e custa caro: variáveis locais não atravessam o pipeline. No imperativo, qualquer variável no escopo está disponível no breakpoint. No reativo, o estado que você precisa carregar entre operadores não está numa variável local — ele precisa fluir pelo próprio fluxo (via zip, tuple, ou o Context do Reactor). Logo, mesmo que você consiga parar num ponto, o que está visível ali raramente é o que você quer inspecionar. O modelo mental de “abrir o programa e olhar por dentro” não se aplica; você passa a raciocinar sobre o fluxo a partir de logs ordenados no tempo — uma mudança real de como se diagnostica um problema.
Contágio: uma lib bloqueante no meio anula o ganho
O modelo não-bloqueante depende de um event loop com poucas threads que nunca podem parar. Se uma chamada bloqueante (um driver JDBC, um SDK síncrono, um Thread.sleep, um .block() mal colocado) roda numa thread do event loop, ela trava o loop inteiro — todas as outras requisições naquela thread ficam paradas. O ganho de escalabilidade evapora, e você ainda paga toda a complexidade reativa.
A doc do Spring põe isso em termos de dependências: “If you have blocking persistence APIs (JPA, JDBC) or networking APIs to use, Spring MVC is the best choice for common architectures at least. It is technically feasible […] to perform blocking calls on a separate thread but you would not be making the most of a non-blocking web stack.” Ou seja: dá para isolar o bloqueante num scheduler separado (Schedulers.boundedElastic()), mas aí você está rodando um modelo de thread pool disfarçado de reativo — sem o benefício, com o custo. É o pior dos dois mundos.
O contágio é especialmente traiçoeiro porque é silencioso em desenvolvimento. Com um usuário e baixa concorrência, a chamada bloqueante na thread do event loop termina rápido e nada parece errado — testes passam, demo funciona. O problema só aparece sob carga real, quando várias requisições disputam as poucas threads do loop e uma chamada lenta congela todas as outras que compartilham aquela thread. É uma bomba-relógio que não dispara no ambiente onde você normalmente a encontraria. Por isso o Reactor oferece o BlockHound — um agente que detecta chamadas bloqueantes em threads não-bloqueantes e estoura na hora; vale a pena no stack reativo justamente porque o olho humano não pega esse erro em revisão de código.
Curva da equipe vs o ganho real: o checklist honesto
Reativo é um modelo de programação declarativo, funcional e não-bloqueante simultaneamente — três mudanças de paradigma de uma vez. A doc do Spring reconhece: “Imperative programming is the easiest way to write, understand, and debug code” e, para times grandes, “keep in mind the steep learning curve in the shift to non-blocking, functional, and declarative programming.” A recomendação oficial é cautelosa: “start small and measure the benefits […] We expect that, for a wide range of applications, the shift is unnecessary.”
A pergunta de decisão não é “reativo é melhor?” — é “o ganho de I/O é grande o bastante para pagar o custo cognitivo permanente desta equipe?“. Na maioria dos CRUDs, a resposta honesta é não.
Na prática
Stack trace fragmentado vs com checkpoint
Sem instrumentação, o trace aponta para as engrenagens do Reactor, não para a sua cadeia:
java.lang.IndexOutOfBoundsException: Index 5 out of bounds for length 3
at java.base/...Objects.checkIndex(Objects.java:385)
at java.base/...ArrayList.get(ArrayList.java:427)
at reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:106)
at reactor.core.publisher.FluxFlatMap$FlatMapMain.onNext(FluxFlatMap.java:386)
at reactor.core.publisher.FluxRange$RangeSubscription.slowPath(FluxRange.java:154)
at reactor.core.publisher.Operators$...request(...)
... (50+ linhas internas do Reactor, nenhuma do SEU código de montagem)Com um checkpoint("após enriquecer usuário") na cadeia, o Reactor anexa o ponto de montagem:
java.lang.IndexOutOfBoundsException: Index 5 out of bounds for length 3
...
Error has been observed at the following site(s):
*__checkpoint ⇢ após enriquecer usuário
Original Stack Trace:
at com.exemplo.UserService.enrich(UserService.java:42) <-- AGORA aponta pro seu códigoA diferença entre 30 minutos e 30 segundos de investigação mora nesse checkpoint. Mas note: você teve que prever onde colocá-lo. No imperativo, esse contexto viria de graça.
Contágio em código: o bloqueante escondido
O anti-padrão clássico — uma chamada bloqueante (JpaRepository) executada direto na thread do event loop:
// PROBLEMA: findById() é bloqueante (JDBC) e roda na thread do event loop Netty.
// Sob carga, trava o loop inteiro — todas as requisições daquela thread param.
@GetMapping("/{id}")
public Mono<User> get(@PathVariable Long id) {
return Mono.fromCallable(() -> jpaRepository.findById(id).orElseThrow());
// sem Scheduler: roda no event loop — contágio silencioso
}Se o bloqueante é inevitável, o mínimo é isolá-lo num scheduler elástico — mas reconhecendo que isso é um thread pool disfarçado, sem o ganho não-bloqueante real:
// MENOS RUIM: empurra o bloqueante pra fora do event loop.
// Honesto, mas é thread pool com roupa reativa — se TODO o stack é assim, use MVC.
return Mono.fromCallable(() -> jpaRepository.findById(id).orElseThrow())
.subscribeOn(Schedulers.boundedElastic());A pergunta que essa correção levanta é a própria tese da nota: se você está empurrando tudo para boundedElastic, por que não Spring MVC + Virtual Threads, que faz isso de forma nativa e step-debuggable?
Casos de fronteira (onde a resposta não é óbvia)
Nem todo caso cai limpo de um lado. Alguns exemplos de fronteira para calibrar o julgamento:
- Stack já reativo, recurso novo é CRUD. Se o sistema já é WebFlux de ponta a ponta e a equipe domina, adicionar mais um endpoint CRUD reativo é coerente — a coerência do codebase pesa mais que o ganho marginal de I/O daquele endpoint isolado. Aqui o reativo “vence” por consistência, não por mérito técnico do caso.
- Microsserviço de gateway/agregação. Chamar vários serviços downstream em paralelo e compor as respostas é um caso onde os operadores reativos (
zip,flatMap) brilham — mesmo sem streaming contínuo. Ainda assim, com Java 21+, structured concurrency + Virtual Threads cobre boa parte disso de forma imperativa. - Picos de concorrência extrema com I/O leve. Antes do Loom, esse era o território natural do reativo. Hoje é exatamente onde Virtual Threads competem de igual para igual — meça antes de assumir que o reativo é necessário.
O padrão dos três casos: a fronteira raramente é resolvida por “reativo é mais rápido”. É resolvida por contexto — codebase existente, forma do problema, versão do Java disponível.
Checklist de decisão
USE REATIVO SE (todos pesam a favor):
[ ] Streaming real — SSE, WebSocket, dados que chegam contínuos (não req/resp simples)
[ ] Backpressure de verdade — produtor mais rápido que consumidor, precisa de request(n)
[ ] Stack JÁ é reativo de ponta a ponta — driver R2DBC, WebClient, sem ilhas bloqueantes
[ ] Equipe domina o modelo OU vai investir sério em aprendê-lo
NÃO USE REATIVO SE (qualquer um pesa contra):
[ ] CRUD comum — req chega, consulta banco, devolve JSON
[ ] Dependência bloqueante no caminho — JPA, JDBC, SDK síncrono (= contágio)
[ ] Equipe sem experiência e sem tempo de onboarding
[ ] Java 21+ disponível — Virtual Threads dão concorrência de I/O com código imperativo
DEFAULT MAIS BARATO p/ CRUD em Java 21+:
Virtual Threads + Spring MVC — imperativo, step-debuggable, stack trace inteiro,
e ainda escala I/O. Reativo vira escolha deliberada, não padrão.A regra de ouro: reativo é uma escolha que você justifica, não um default que você assume.
Armadilhas
(1) Reativo por hype / CV-driven development
Adotar WebFlux porque “é moderno”, “todo mundo usa” ou para enfeitar o currículo da equipe — sem nenhum requisito de streaming ou backpressure que justifique.
Exemplo: uma API de cadastro de clientes (POST cria, GET lista, PUT edita) reescrita em WebFlux + R2DBC. Zero streaming, zero backpressure, tráfego modesto. O time agora gasta o dobro do tempo em cada bug porque os stack traces não ajudam e o step debugging sumiu. O ganho de escalabilidade nunca foi necessário — a carga sempre coube num thread pool comum.
Fix: exija um requisito concreto antes de ir reativo. Se você não consegue nomear o streaming ou o backpressure que está resolvendo, não há o que resolver. “We expect that, for a wide range of applications, the shift is unnecessary.”
(2) Reativo “pela metade” — uma lib bloqueante no meio anula tudo
Montar todo o stack reativo, mas manter uma única chamada bloqueante no caminho quente — um repositório JPA, um SDK síncrono de terceiros, um .block() “só nesse caso”.
Exemplo: controller WebFlux → service que chama jpaRepository.findById() (bloqueante) direto numa thread do event loop Netty. Sob carga, essa chamada trava a thread do loop; como o Netty tem poucas threads, poucas requisições lentas congelam o servidor inteiro. Você tem toda a complexidade reativa e o gargalo do bloqueante — o pior dos dois mundos.
Fix: ou o stack é reativo de ponta a ponta (R2DBC em vez de JPA, WebClient em vez de cliente síncrono), ou você isola o bloqueante explicitamente em Schedulers.boundedElastic() — e aí reconhece que está rodando um thread pool disfarçado, sem o ganho não-bloqueante. “You would not be making the most of a non-blocking web stack.” Se há bloqueante inevitável, Spring MVC é mais honesto.
(3) Subestimar o custo de onboarding e debugging
Tratar a curva de aprendizado como um custo único de setup, quando ela é um imposto permanente sobre cada bug, cada nova contratação e cada plantão.
Exemplo: o arquiteto domina Reactor e entrega o sistema. Seis meses depois, ele saiu; o time remanescente leva horas para diagnosticar um erro porque o stack trace é opaco e ninguém sabe onde colocar checkpoint(). O onboarding de um júnior, que seria de dias num código imperativo, vira de semanas. O custo não estava no setup — estava na operação contínua.
Fix: ao estimar a adoção, conte o custo recorrente: debugging mais lento, onboarding mais longo, bus factor menor. “If you have a large team, keep in mind the steep learning curve in the shift to non-blocking, functional, and declarative programming.” Comece pequeno, meça o ganho, e só expanda se o benefício real superar esse imposto.
Em entrevista
Frase pronta (inglês)
Reactive programming isn’t a free upgrade — it’s a trade. You gain non-blocking I/O efficiency, but you pay a real cognitive cost: stack traces are fragmented because the thread that throws isn’t the one that assembled the chain, and you lose interactive step-debugging through a lazy, asynchronous pipeline. The contagion problem is the subtle killer — a single blocking dependency like a JPA or JDBC call on the event loop negates the entire benefit, so you end up with all the complexity and none of the gain. My default for ordinary CRUD on Java 21 and up is Virtual Threads with Spring MVC: imperative code that still scales I/O, stays step-debuggable, and keeps full stack traces. I reach for reactive only when there’s a concrete justification — real streaming, genuine backpressure, or a stack that’s already reactive end to end. As the Spring docs put it, if your MVC application works fine, there’s no need to change.
Vocabulário
| Termo PT | Termo EN |
|---|---|
| custo cognitivo | cognitive cost / cognitive load |
| stack trace fragmentado | fragmented stack trace |
| ponto de montagem (da cadeia) | assembly point |
| depuração interativa / passo a passo | interactive / step debugging |
| contágio (lib bloqueante) | blocking contagion |
| event loop | event loop |
| curva de aprendizado acentuada | steep learning curve |
| de ponta a ponta | end to end |
Veja também
- Reativo vs Virtual Threads
- Schedulers
- Virtual Threads e Project Loom (Galho 4 — a alternativa)
- O que é Spring MVC (Galho 9 — o default imperativo)
- Programação Reativa (MOC do galho)
- Trilha Java
- Dicionário de Java
Referências
- Project Reactor Reference — Debugging a Reactor Application (
Hooks.onOperatorDebug(),checkpoint(), assembly vs execução): https://projectreactor.io/docs/core/release/reference/debugging.html - Spring Framework Reference — Web on Reactive Stack / WebFlux Applicability (quando usar WebFlux vs MVC, dependências bloqueantes, curva de aprendizado): https://docs.spring.io/spring-framework/reference/web/webflux/new-framework.html