Reactive Without RxJava Trauma: Project Reactor Patterns That Age Well
The Honest Starting Point
Most teams adopt reactive programming under pressure. A service is slow under load, someone benchmarks WebFlux and sees numbers, the next sprint includes migrating a critical path to Project Reactor. Six months later the codebase is littered with flatMap chains nobody fully understands, error handling that silently swallows exceptions, and a debugging story that amounts to "add more logs and pray."
This is not an argument against reactive. It is an argument for doing it deliberately. Project Reactor is a powerful tool with a narrow ideal use case: I/O-bound workloads where you need high concurrency without proportional thread count. Used there, with patterns that hold up over time, it pays its complexity cost. Used elsewhere, or used carelessly, it creates the kind of technical debt that takes years to unwind.
Here are the patterns that age well.
Backpressure Is Not Optional
Backpressure is the mechanism by which a subscriber tells a publisher "slow down, I can only handle N items right now." In Project Reactor, this is built into the Flux contract via the Subscription.request(n) method. Most developers never think about it explicitly — and that is the problem.
The default behavior in many operators is to request Long.MAX_VALUE items immediately. This is fine when your source is a bounded collection in memory. It is not fine when your source is a Kafka topic, a database result set, or any upstream service that can produce items faster than you can consume them.
The buffer in that diagram is where things go wrong. If the subscriber requests Long.MAX_VALUE and the publisher is fast, the buffer grows unbounded. The JVM does not tell you this is happening. You just see heap pressure, GC pauses, and eventually OutOfMemoryError.
Pattern: use limitRate() at subscription time.
Flux.fromIterable(largeDataSource)
.limitRate(256) // request 256, refill at 75% threshold
.flatMap(item -> processAsync(item), 16) // max 16 concurrent
.subscribe();limitRate(256) tells Reactor to issue request(256) initially and refill when 75% (192 items) have been processed. The second argument to flatMap caps concurrency at 16 in-flight operations. Together, these two numbers give you a memory-bounded, concurrency-bounded pipeline that degrades gracefully under load.
When to use onBackpressureBuffer() vs onBackpressureDrop():
Use onBackpressureBuffer(maxSize, overflow -> handleOverflow()) when losing data is unacceptable and you have a defined maximum queue size. Use onBackpressureDrop() when you are streaming metrics or telemetry where dropping a few data points is acceptable and throughput matters more than completeness.
// telemetry pipeline — drop is fine
metricsFlux
.onBackpressureDrop(dropped -> log.warn("Dropped metric: {}", dropped.name()))
.flatMap(this::writeToInflux)
.subscribe();
// event pipeline — buffer with alarm
eventFlux
.onBackpressureBuffer(1000, event -> {
metrics.increment("event.buffer.overflow");
alerting.trigger("Event queue overflow");
})
.flatMap(this::persistEvent)
.subscribe();Error Handling That Does Not Lie to You
The most dangerous pattern in reactive codebases is swallowed exceptions. Because errors propagate as signals in a Flux or Mono, a missing error handler means the sequence terminates silently. The calling code never knows something went wrong.
// this is a trap
service.fetchUser(id)
.map(User::getProfile)
.subscribe(profile -> display(profile));
// if fetchUser emits an error, nothing happens. No log, no fallback, nothing.The subscriber overload with a separate error consumer is marginally better but still easy to miss:
service.fetchUser(id)
.subscribe(
profile -> display(profile),
error -> log.error("Failed to fetch user {}", id, error)
);Pattern: define error boundaries at the boundary of each logical operation.
service.fetchUser(id)
.map(User::getProfile)
.onErrorResume(UserNotFoundException.class, ex ->
Mono.just(Profile.anonymous()))
.onErrorMap(TimeoutException.class, ex ->
new ServiceUnavailableException("user-service", ex))
.onErrorResume(ex -> {
log.error("Unexpected error fetching user {}", id, ex);
return Mono.error(ex); // re-emit after logging
});The order matters. onErrorResume for known recoverable errors comes first. onErrorMap translates low-level exceptions into domain exceptions. The catch-all at the end logs and re-emits — it does not swallow.
Pattern: use doOnError() for side effects, not for handling.
service.fetchOrder(orderId)
.doOnError(ex -> metrics.increment("order.fetch.error", ex.getClass().getSimpleName()))
.doOnError(ex -> span.setStatus(StatusCode.ERROR))
.onErrorMap(ex -> new OrderServiceException(orderId, ex));doOnError is for observability hooks. It does not affect the error signal — the sequence still terminates with an error after doOnError runs. This distinction is important and frequently misunderstood.
Schedulers: Know Where Your Work Runs
One of the most common performance pitfalls in Reactor code is blocking work on the wrong scheduler. Reactor uses a small number of event loop threads by default (specifically Schedulers.parallel() uses Runtime.getRuntime().availableProcessors() threads). Blocking any of these threads degrades the throughput of everything sharing that scheduler.
Rule: CPU-bound work on Schedulers.parallel(). Blocking I/O on Schedulers.boundedElastic(). Never block on the default scheduler.
Mono.fromCallable(() -> jdbcTemplate.queryForObject(sql, rowMapper))
.subscribeOn(Schedulers.boundedElastic()) // run the blocking call here
.map(result -> transform(result)) // back on parallel after
.publishOn(Schedulers.parallel());subscribeOn affects where the subscription (and therefore the source) runs. publishOn shifts the downstream processing to a new scheduler. They have different effects and different placement implications — this is one of the most commonly confused aspects of Reactor.
Debugging Without Losing Your Mind
Reactive stack traces are infamous. An exception thrown inside a flatMap three operators deep shows you Reactor internals, not your code. The useful context — what data was being processed, which operator triggered the error — is gone.
Pattern: enable Hooks.onOperatorDebug() in development, never in production.
// in your @SpringBootApplication or test configuration
@Profile("dev")
@PostConstruct
public void enableReactorDebug() {
Hooks.onOperatorDebug();
}This captures assembly-time stack traces for every operator, making error traces readable. The cost is significant (roughly 5-10x overhead) which is why it must never run in production.
Pattern: use checkpoint() as surgical debug markers.
userService.findById(id)
.checkpoint("after-user-lookup")
.flatMap(user -> orderService.findByUser(user))
.checkpoint("after-order-lookup")
.map(orders -> buildResponse(orders))
.checkpoint("after-response-build");checkpoint() is cheap enough to leave in production during an incident investigation. It adds a descriptive label to stack traces without the full overhead of Hooks.onOperatorDebug().
Pattern: use log() for transient tracing.
pipeline
.log("com.acme.order.pipeline", Level.DEBUG, SignalType.ON_NEXT, SignalType.ON_ERROR)
.subscribe();log() emits Reactor signals to a logger. This is invaluable for understanding exactly what signals flow through a pipeline during debugging. Remove it when done — it is noisy.
When to Drop Reactive
This is the opinion most reactive tutorials omit: there are workloads where reactive genuinely makes things worse.
Drop reactive when:
- Your workload is CPU-bound. Reactive does not help with computation. It adds overhead.
- Your team is small and reactive literacy is low. The debugging and mental model costs are real.
- You are wrapping blocking libraries (JDBC, legacy clients). Wrapping blocking code in
Mono.fromCallable()withboundedElasticis worse than just using platform threads or virtual threads. - You need Java 21 virtual threads anyway. If you have Java 21, virtual threads on Spring MVC give you comparable throughput for most I/O-bound workloads with dramatically simpler code.
The teams that get the most out of reactive programming are those that use it where the model fits — high-fan-out I/O, streaming pipelines, real-time event processing — and resist the temptation to apply it everywhere.
Practical Checklist
Before shipping any reactive pipeline to production, verify:
- Every
subscribe()call has an error consumer or the chain terminates in a framework that handles errors (Spring WebFlux, Spring Integration, etc.) - Blocking calls are isolated on
boundedElastic, never onparallel - Unbounded
flatMapconcurrency is replaced with a concurrency argument - At least one
checkpoint()exists in complex chains for debugging - Backpressure strategy is explicit, not left to default behavior
- The pipeline has been load tested, not just unit tested
Key Takeaways
- Backpressure is active work, not a default behavior — use
limitRate()and set explicit concurrency onflatMap. - Silent error swallowing is the most dangerous reactive bug; define error boundaries at every logical operation boundary.
subscribeOnandpublishOnhave different semantics; confusing them leads to blocking the wrong scheduler silently.Hooks.onOperatorDebug()is for local development only; usecheckpoint()for production-safe tracing.- Reactive is a poor fit for CPU-bound work, wrapping blocking libraries, and teams without strong reactive literacy.
- Java 21 virtual threads eliminate many of the concurrency arguments for reactive in new services; evaluate whether you still need it.