DEV Community

Venkatesan Ramar
Venkatesan Ramar

Posted on AI-assisted

Reactive Spring Applications: Architecture, Trade-offs, and Production Realities

This article covers fundamentals of Spring WebFlux, blocking dependencies, backpressure and resilience.

Imagine a backend service receiving thousands of concurrent requests. Each request performs a small amount of computation but spends most of its lifetime waiting for a database query, a remote API, or another microservice to respond. The service is not necessarily CPU-bound. Its resources are tied up waiting for work to complete.

A traditional Spring MVC application handles requests using a thread-per-request model. This is straightforward and works well for many workloads, but concurrency can become expensive when large numbers of threads remain occupied by blocking I/O. Threads consume memory and scheduling resources, and increasing the thread pool indefinitely is not a sustainable way to handle growing concurrency.

Spring WebFlux approaches this problem differently. Instead of requiring a dedicated application thread to remain occupied while every I/O operation completes, it uses non-blocking I/O and reactive composition to allow execution to progress when results become available.

That sounds like an obvious improvement, but there is an important distinction: non-blocking execution can improve resource efficiency without necessarily making an individual request faster. A database query still takes as long as the database needs. A remote service still introduces network latency. Reactive programming changes how the application manages waiting, composes asynchronous work, and responds to demand.

Understanding that distinction is essential to deciding whether reactive Spring is the right architectural choice.

1. Why Reactive Spring Exists

The conventional thread-per-request model is attractive because its execution flow closely resembles the business process. A controller calls a service, the service queries a database, and the result is returned to the caller. When a method performs blocking I/O, however, the executing thread waits until that operation completes.

At modest concurrency, this is rarely a problem. As concurrent requests increase, more threads may spend their time waiting rather than doing useful work. Increasing the thread pool can help up to a point, but it also increases resource consumption and scheduling overhead. The actual bottleneck may eventually move to the database connection pool or a downstream service rather than the application threads themselves.

Spring WebFlux is designed for workloads where many concurrent operations spend significant time waiting on I/O. Its non-blocking execution model allows a relatively small number of event-loop threads to coordinate a much larger number of in-flight requests, provided the application and its dependencies do not block those threads.

Reactive programming also introduces Reactive Streams backpressure, which provides a mechanism for consumers to signal how much data they are ready to receive. This becomes useful when producers can generate data faster than downstream consumers can process it.

Neither capability guarantees lower latency or higher throughput in every application. A CPU-intensive service may benefit little from non-blocking I/O, while a conventional CRUD service using blocking JPA calls may gain complexity without removing its primary bottleneck.

The architectural question is therefore not whether reactive programming is more modern. It is whether the workload benefits from non-blocking concurrency, asynchronous composition, or controlled data flow enough to justify the associated complexity.

2. Reactive Foundations: Mono, Flux, and Reactive Streams

Project Reactor, the reactive library commonly used with Spring WebFlux, provides two fundamental publisher types: Mono and Flux.

A Mono<T> represents an asynchronous computation that can produce zero or one value, followed by completion, or terminate with an error. A Flux<T> represents a sequence that can produce zero to many values, followed by completion or an error. These types describe the shape of the result; they do not, by themselves, guarantee that the underlying work is asynchronous or non-blocking.

Consider a reactive repository:

Mono<Customer> customer =
    customerRepository.findById(customerId);

Flux<Order> orders =
    orderRepository.findByCustomerId(customerId);
Enter fullscreen mode Exit fullscreen mode

The first publisher represents an optional customer result. The second represents a stream of orders associated with that customer.

Reactive Streams defines the interaction between publishers, subscribers, and demand. A subscriber can request a certain number of elements, allowing a compliant publisher to regulate how much data it emits. This is the foundation of backpressure, although its effectiveness depends on how the entire pipeline and its external dependencies handle demand.

Another important characteristic is that Reactor pipelines are generally lazy. Declaring a publisher does not necessarily execute the work it describes. Execution is initiated when something subscribes to the publisher, or when a framework component such as WebFlux subscribes as part of handling an HTTP request.

Mono<Customer> customer =
    customerRepository.findById(customerId)
        .map(this::enrichCustomer);
Enter fullscreen mode Exit fullscreen mode

This declares a pipeline. In a typical WebFlux controller, returning the Mono allows the framework to manage subscription and response handling. Application code generally should not introduce arbitrary manual subscriptions inside request processing, because doing so can detach work from the request lifecycle and make error propagation and cancellation harder to manage.

The important point is that Mono and Flux are not simply replacements for ordinary Java return types. They represent computations whose execution, completion, failure, and demand are managed through a reactive protocol.

3. How Spring WebFlux Executes Requests

WebFlux commonly uses an event-loop-based execution model, particularly with Reactor Netty. A small number of event-loop threads process network activity and advance reactive pipelines as I/O operations complete. When a properly non-blocking operation is waiting for network data, the thread can process other work instead of remaining blocked on that operation.

A simplified request lifecycle looks like this:

Incoming HTTP Request
         |
         v
   WebFlux Handler
         |
         v
   Reactive Pipeline
         |
         +---- Non-blocking I/O
         |          |
         |          v
         |    Thread is available
         |    for other work
         |          |
         |          v
         |    I/O completion signal
         |          |
         +----------+
                    |
                    v
             HTTP Response
Enter fullscreen mode Exit fullscreen mode

The key benefit is not that one thread performs every operation. It is that the application avoids occupying threads while waiting for supported non-blocking I/O.

This is also why blocking operations are particularly dangerous on event-loop threads. If a handler calls a synchronous database driver or waits on a blocking SDK, the event-loop thread cannot progress other work assigned to it until that call returns. A single blocking dependency can therefore undermine the concurrency model the application was designed to exploit.

Reactor provides publishOn and subscribeOn to influence execution context. publishOn changes the scheduler used for subsequent downstream operators, while subscribeOn influences where subscription and upstream execution begin. Their exact effects depend on the pipeline and source.

Neither operator transforms a blocking API into a non-blocking API. Scheduling a blocking call on an event loop still blocks that event loop. Moving it to a dedicated scheduler can isolate the impact, but the operation remains blocking and still consumes a thread while it waits.

That distinction should guide design reviews: thread switching is an execution decision, not a substitute for understanding the blocking behavior of dependencies.

4. Building a Reactive Request Flow

Consider an Order Summary API that retrieves an order and then uses the order's customer identifier to retrieve customer details. The second request depends on the first, so the pipeline should express that dependency explicitly.

public Mono<OrderSummary> getOrderSummary(String orderId) {
    return orderClient.getOrder(orderId)
        .flatMap(order ->
            customerClient.getCustomer(order.customerId())
                .map(customer ->
                    new OrderSummary(order, customer)
                )
        );
}
Enter fullscreen mode Exit fullscreen mode

The flatMap operator composes the asynchronous customer lookup after the order response becomes available. The customer request is not started until the order provides the required customer identifier. The resulting Mono<OrderSummary> represents the complete operation.

This is more than a different syntax for nested callbacks. The dependency is visible in the pipeline, and failures from either request can propagate through the composed result.

Now consider a product view that requires inventory and pricing information. If the two calls are independent, they can be composed without waiting for one to finish before starting the other:

public Mono<ProductView> getProductView(String productId) {
    return Mono.zip(
        inventoryClient.getInventory(productId),
        pricingClient.getPrice(productId)
    ).map(tuple ->
        new ProductView(tuple.getT1(), tuple.getT2())
    );
}
Enter fullscreen mode Exit fullscreen mode

When the returned publisher is subscribed to, both source publishers can progress independently. The combined result becomes available when both have produced their required values. This can reduce the time spent waiting sequentially, but only when the calls are genuinely independent and the underlying clients support asynchronous execution.

Reactive composition does not remove the need to define failure behavior. If either call fails, the combined publisher normally fails unless the pipeline explicitly handles that failure. If pricing is optional, for example, the application might choose a fallback value rather than fail the entire response.

Timeouts and cancellation matter for the same reason. A request that has already exceeded its useful deadline should not continue consuming downstream capacity unnecessarily. At the same time, cancellation of a reactive publisher does not guarantee that every external operation can be forcibly stopped. The client, protocol, and downstream service must support the relevant cancellation semantics.

The design objective is to make dependencies, concurrency, and failure behavior explicit—not merely to express ordinary business logic with reactive operators.

5. The Hidden Complexity: Blocking Dependencies and Data Access

The largest architectural mistake in reactive applications is adopting WebFlux at the HTTP layer while leaving the rest of the application predominantly blocking.

Consider a service that uses WebFlux controllers but relies on Spring Data JPA and JDBC for persistence. These technologies are built around blocking database access. Wrapping a repository call in a Mono does not change the behavior of the JDBC driver or turn the database interaction into non-blocking I/O.

For a genuinely reactive persistence path, Spring Data R2DBC provides database access through Reactive Streams-based APIs and supported reactive drivers. That can allow database operations to participate in a non-blocking pipeline, but it does not automatically guarantee better performance. Database capacity, connection limits, query efficiency, transaction behavior, and downstream contention still determine the practical outcome.

The same issue arises with third-party SDKs, legacy clients, filesystem operations, and libraries that expose only synchronous APIs. Sometimes replacing these dependencies is not feasible. In that case, blocking work can be isolated on an appropriate scheduler:

Mono<Data> data =
    Mono.fromCallable(() -> legacyClient.fetchData())
        .subscribeOn(Schedulers.boundedElastic());
Enter fullscreen mode Exit fullscreen mode

This schedules the callable on Reactor's bounded-elastic scheduler, which is intended for blocking work. It protects event-loop threads from directly executing the blocking call, but it does not eliminate the cost of waiting or create unlimited capacity. Scheduler saturation, queueing, and downstream limits still need to be considered.

The escape hatch is useful when blocking work is unavoidable. It is not a strong reason to make an entire application reactive while retaining blocking persistence and most of its existing execution model.

Connection pools introduce another constraint. Non-blocking execution can support many concurrent in-flight requests, but the database cannot necessarily execute thousands of queries concurrently. If the connection pool has limited capacity, additional requests may queue for connections. Increasing application concurrency without managing database capacity can move the bottleneck rather than remove it.

This is why Spring MVC can be the more appropriate choice for conventional applications built around blocking JPA/JDBC access. A coherent blocking architecture is often easier to understand and operate than a partially reactive one.

6. Backpressure, Errors, and Resilience

Reactive Streams backpressure provides a mechanism for consumers to communicate demand to publishers. This can help control data flow in streaming pipelines, particularly when the producer and consumer can coordinate how many elements are requested and emitted.

However, backpressure is not an automatic, end-to-end guarantee across every system in an application. A database, HTTP client, message broker, or external API may have its own buffering and flow-control behavior. If a producer cannot slow down, the application must decide whether to buffer, drop, reject, or fail work. Unbounded buffering simply converts a throughput mismatch into a memory problem.

Resilience also requires deliberate error and timeout policies. Consider a downstream status lookup:

return paymentClient.fetchStatus(paymentId)
    .timeout(Duration.ofSeconds(2))
    .retryWhen(
        Retry.backoff(2, Duration.ofMillis(100))
            .filter(this::isTransientFailure)
    );
Enter fullscreen mode Exit fullscreen mode

This example applies a two-second timeout and retries selected transient failures with backoff, allowing up to two retries under the configured policy. The exact overall deadline depends on the timeout's placement and the time consumed by retries, so production code should define the intended request deadline explicitly.

Retries are not inherently safe. If a request triggers a side effect and the client times out before receiving the response, the operation might already have succeeded remotely. Retrying it can duplicate that effect unless the operation is idempotent or protected by an idempotency mechanism. During an outage, aggressive retries can also amplify load on an already struggling dependency.

Cancellation, circuit breakers, and bulkheads address related concerns. Cancellation can stop unnecessary downstream work when supported. Circuit breakers can prevent repeated calls to a failing dependency, while bulkheads limit how much capacity one dependency or workload can consume.

Reactive programming provides tools for composing these policies, but it does not choose the correct policies for the business. Those decisions still depend on deadlines, retry safety, downstream capacity, and the consequences of partial failure.

7. Reactive Spring vs. Spring MVC with Virtual Threads

Java 21 introduced virtual threads, giving Spring MVC applications another way to handle high concurrency while retaining an imperative programming model. Virtual threads are lightweight threads managed by the JVM, making it practical to support many concurrent blocking tasks without the same platform-thread cost associated with traditional thread-per-request designs.

This changes the decision between WebFlux and Spring MVC. Reactive programming is no longer the only option worth considering when a service needs to support many concurrent I/O-bound requests.

Consideration Spring WebFlux Spring MVC with Virtual Threads
Programming model Reactive composition Imperative, blocking-friendly
Concurrency approach Non-blocking I/O Lightweight virtual threads
Backpressure Reactive Streams support Requires other mechanisms where applicable
JDBC/JPA integration Blocking calls require careful isolation Natural fit for blocking APIs
Streaming Strong fit for reactive streaming Depends on API and workload
Complexity Higher learning and debugging cost Often simpler for conventional business workflows

Virtual threads reduce the cost of blocking threads; they do not make blocking I/O non-blocking. Nor do they remove database connection limits, downstream bottlenecks, or the need to manage concurrency. A service can create many virtual threads while still overwhelming a database with too many concurrent queries.

WebFlux remains attractive when the application benefits from non-blocking clients, streaming, reactive composition, or explicit demand management. Spring MVC with virtual threads can be a better fit when the application is primarily request-response business logic built around blocking libraries and the team values a straightforward imperative flow.

Neither option is universally superior. The decision should reflect the dependency stack, workload characteristics, operational requirements, and the team's ability to debug and maintain the chosen model.

8. Production Observability and Testing

Reactive code that passes unit tests can still fail under contention, slow downstream responses, scheduler saturation, or connection-pool exhaustion. Production validation must therefore cover more than successful responses.

Track latency, throughput, error rates, timeout rates, and connection-pool saturation. For WebFlux applications, investigate blocked event-loop threads and scheduler saturation. Distributed traces should preserve request context across asynchronous boundaries so that a slow dependency can be identified without reconstructing the entire execution path from logs.

Reactor's StepVerifier can test publisher behavior, including emitted values, completion, errors, and time-dependent behavior when configured appropriately. Those tests are useful for validating reactive logic, but they do not replace integration tests against real databases, brokers, and network clients. The integration layer should validate the behavior that unit tests intentionally simulate.

Load tests are equally important. Benchmark with realistic downstream latency, representative concurrency, meaningful payload sizes, and realistic resource limits. Compare the same workload and infrastructure when evaluating WebFlux against MVC or virtual threads. Synthetic benchmarks that exclude the actual bottleneck can make an architectural choice appear better without demonstrating a production benefit.

The outcome to measure is not the number of reactive operators or the number of requests a test can generate. It is whether the chosen architecture improves the service's resource efficiency, throughput, latency distribution, or resilience under the workload it actually serves.

9. A Practical Decision Framework

Before adopting reactive Spring, ask whether the workload is predominantly I/O-bound, whether its important dependencies support non-blocking access, and whether streaming or backpressure solves a real problem. Evaluate whether the team can operate and debug reactive pipelines effectively, and compare the design with Spring MVC using virtual threads where appropriate. Finally, use representative load tests to demonstrate a measurable improvement before accepting the additional complexity.

Reactive programming is valuable when its execution model matches the workload and the surrounding infrastructure supports it. It is not a universal performance upgrade, and Mono and Flux are not evidence of a scalable architecture by themselves.

Choose reactive programming because the workload benefits from non-blocking composition and flow control—not because Mono and Flux look more modern than imperative Java.

Top comments (0)