Reactive Programming in Spring Boot — WebFlux, When to Use It, and When Not To

by Eric Hanson, Backend Developer at Clean Systems Consulting

What WebFlux solves

Traditional Spring MVC assigns one thread per request. While that thread waits on a database query or an HTTP call to an external service, it's blocked — holding memory, holding a thread pool slot, doing nothing. A Tomcat thread pool of 200 threads can handle 200 concurrent blocking operations. The 201st request waits.

Spring WebFlux uses an event loop model. A small number of threads (typically equal to CPU core count) handle all requests by never blocking. When an operation would block — a database query, an HTTP call — it's offloaded to a reactive pipeline that continues when the result arrives. The event loop thread is immediately available for other requests.

This is the fundamental scaling advantage: the same hardware handles far more concurrent I/O-bound requests with WebFlux than with Spring MVC under the same conditions.

Java 21 virtual threads narrow this gap significantly. A virtual thread that blocks on I/O doesn't hold an OS thread — the JVM scheduler parks it and runs another virtual thread. Spring MVC with virtual threads achieves similar concurrency to WebFlux for I/O-bound workloads without changing the programming model.

The decision in 2024: if you're on Java 21+, virtual threads with Spring MVC often provide the concurrency benefits of WebFlux without its complexity. WebFlux remains the right choice for specific patterns — streaming responses, server-sent events, reactive database drivers (R2DBC), and systems that are architecturally committed to reactive pipelines.

The programming model

WebFlux uses Project Reactor's Mono<T> (zero or one result) and Flux<T> (zero or many results) as the reactive types:

// Spring MVC — blocking, imperative
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
    Order order = orderService.findOrder(id);    // blocks the thread
    return OrderResponse.from(order);
}

// WebFlux — non-blocking, reactive
@GetMapping("/orders/{id}")
public Mono<OrderResponse> getOrder(@PathVariable Long id) {
    return orderService.findOrder(id)            // returns Mono<Order>
        .map(OrderResponse::from);
}

orderService.findOrder(id) in the WebFlux version returns Mono<Order> — a promise that an order will eventually arrive. The .map(OrderResponse::from) is registered as a transformation to apply when the order arrives. Nothing executes immediately. The framework subscribes to the Mono and the event loop handles the result when the reactive data source (R2DBC, WebClient) completes the operation.

Flux for collections and streams:

@GetMapping("/orders")
public Flux<OrderResponse> listOrders() {
    return orderRepository.findByStatus(OrderStatus.PENDING)  // returns Flux<Order>
        .map(OrderResponse::from);
}

// Server-sent events — stream to the client as data arrives
@GetMapping(value = "/orders/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<OrderResponse> streamOrders() {
    return orderRepository.findByStatus(OrderStatus.PENDING)
        .map(OrderResponse::from)
        .delayElements(Duration.ofMillis(100)); // artificial delay for demo
}

TEXT_EVENT_STREAM_VALUE produces a server-sent events (SSE) stream — the client connection stays open and responses arrive as they're available. WebFlux handles this natively; Spring MVC requires SseEmitter and explicit thread management.

Reactive operators — the vocabulary you need

Project Reactor's operator API is extensive. The operators you'll use constantly:

map — synchronous transformation, one-to-one:

orderMono.map(order -> OrderResponse.from(order))

flatMap — asynchronous transformation, returns a Mono or Flux:

orderMono.flatMap(order -> userService.findUser(order.getUserId())) // returns Mono<User>

filter — keep elements matching a predicate:

orderFlux.filter(order -> order.getTotal().compareTo(BigDecimal.valueOf(100)) > 0)

switchIfEmpty — handle empty Mono:

orderMono.switchIfEmpty(Mono.error(new OrderNotFoundException(id)))

onErrorReturn / onErrorResume — handle errors:

orderMono.onErrorReturn(TimeoutException.class, Order.defaultOrder())
orderMono.onErrorResume(ex -> fallbackService.findOrder(id))

zip — combine multiple Monos when all complete:

Mono.zip(orderMono, userMono)
    .map(tuple -> new OrderWithUser(tuple.getT1(), tuple.getT2()))

flatMapMany — convert Mono<List<T>> to Flux<T>:

ordersMono.flatMapMany(Flux::fromIterable)

collectList — convert Flux<T> to Mono<List<T>>:

orderFlux.collectList()

The database layer — R2DBC

Spring Data JPA is blocking — it uses JDBC, which blocks threads. For a fully non-blocking stack, use R2DBC (Reactive Relational Database Connectivity) with Spring Data R2DBC:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
    <groupId>io.r2dbc</groupId>
    <artifactId>r2dbc-postgresql</artifactId>
</dependency>
public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {
    Flux<Order> findByStatus(OrderStatus status);
    Mono<Order> findByIdAndUserId(Long id, Long userId);
}

R2DBC repositories return Mono and Flux — the operations are non-blocking. The event loop thread handles many concurrent database operations without blocking.

The tradeoff: R2DBC does not support JPA features — no @ManyToOne, no @OneToMany, no lazy loading, no Hibernate-level caching. Relationships must be assembled manually via explicit queries and flatMap. A query that in JPA is order.getLineItems() becomes:

Mono<Order> orderWithItems = orderRepository.findById(orderId)
    .flatMap(order ->
        lineItemRepository.findByOrderId(order.getId())
            .collectList()
            .map(items -> {
                order.setLineItems(items);
                return order;
            })
    );

This is more explicit, more verbose, and requires thinking about queries that JPA handles transparently. For teams familiar with JPA, this is a significant productivity cost.

Error handling in reactive pipelines

Error handling in reactive code is different from try-catch. Errors propagate through the pipeline as signals:

public Mono<OrderResponse> getOrder(Long id) {
    return orderRepository.findById(id)
        .switchIfEmpty(Mono.error(new OrderNotFoundException(id)))
        .map(OrderResponse::from)
        .onErrorMap(DataAccessException.class,
            ex -> new ServiceUnavailableException("Database unavailable", ex))
        .timeout(Duration.ofSeconds(5))
        .onErrorReturn(TimeoutException.class, OrderResponse.fallback());
}

switchIfEmpty handles the empty case (not found). onErrorMap converts infrastructure exceptions to domain exceptions. timeout adds a deadline. onErrorReturn provides a fallback for timeouts.

The @ExceptionHandler in a @ControllerAdvice works with WebFlux — the framework catches exceptions from Mono/Flux completions and routes them to the appropriate handler. The exception handler doesn't need to be reactive itself:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("not_found", ex.getMessage()));
    }
}

Blocking code in a reactive pipeline — the critical mistake

The event loop thread must never block. Calling a blocking method inside a reactive pipeline starves the event loop and eliminates WebFlux's concurrency benefit:

// WRONG — blocks the event loop thread
public Mono<OrderResponse> getOrder(Long id) {
    return Mono.fromCallable(() -> {
        Order order = jdbcOrderRepository.findById(id).orElseThrow(); // BLOCKS
        return OrderResponse.from(order);
    });
    // The event loop thread is blocked during the JDBC call
}

// Correct — offload blocking call to a bounded thread pool
public Mono<OrderResponse> getOrder(Long id) {
    return Mono.fromCallable(() -> jdbcOrderRepository.findById(id).orElseThrow())
        .subscribeOn(Schedulers.boundedElastic()) // run on a dedicated blocking thread pool
        .map(OrderResponse::from);
}

Schedulers.boundedElastic() is a thread pool designed for blocking I/O in reactive pipelines. The event loop thread delegates the blocking call to a bounded elastic thread and handles the result when it completes.

This is the escape hatch for mixing reactive and blocking code. It works but eliminates the event loop efficiency for that operation — the thread pool is bounded and can saturate under load. The ideal is a fully non-blocking stack; subscribeOn(Schedulers.boundedElastic()) is the pragmatic bridge when that's not achievable.

When WebFlux is the right choice

Streaming responses. SSE and WebSocket endpoints where the server pushes data to clients as it arrives — live dashboards, notification feeds, chat. Spring MVC handles SSE with SseEmitter but requires explicit thread management. WebFlux handles it natively with Flux.

High-concurrency HTTP proxy or API gateway. A service that receives a request and fans out to multiple upstream services, aggregates the results, and returns. Mono.zip runs the upstream calls concurrently; the event loop handles the aggregation without blocking:

public Mono<DashboardResponse> getDashboard(Long userId) {
    Mono<UserProfile> profile = userService.getProfile(userId);
    Mono<List<Order>> orders  = orderService.getRecentOrders(userId);
    Mono<Balance> balance     = accountService.getBalance(userId);

    return Mono.zip(profile, orders, balance)
        .map(t -> new DashboardResponse(t.getT1(), t.getT2(), t.getT3()));
}

The three upstream calls run concurrently. The total latency is max(profile, orders, balance) rather than their sum.

Reactive database drivers with R2DBC. When the database layer is already reactive, the full reactive stack is coherent and efficient.

When WebFlux is the wrong choice

Teams without reactive experience. Reactive programming has a steep learning curve. The debugging experience is poor — stack traces are asynchronous and hard to read. Thread.currentThread() in a reactive pipeline is meaningless. The mental model is fundamentally different from imperative code. For teams new to reactive, the productivity cost is high and the bugs are subtle.

Applications with complex domain logic. Reactive code that branches, has multiple conditional paths, or handles complex error scenarios becomes harder to read than its imperative equivalent. The operator chain that seems elegant for simple pipelines becomes unwieldy for complex business logic.

Applications primarily using JPA/Hibernate. JPA is blocking. Using WebFlux with JPA requires wrapping everything in subscribeOn(Schedulers.boundedElastic()), which negates the event loop efficiency. Either use R2DBC (large migration, feature reduction) or stay with Spring MVC.

Java 21+ applications. Virtual threads provide similar I/O concurrency to WebFlux with Spring MVC's simpler programming model. For new services on Java 21, virtual threads are almost always the better tradeoff.

The adoption decision: WebFlux is the right choice for new services with streaming requirements, teams with reactive experience, and architectures where the non-blocking guarantee throughout the stack is achievable and necessary. For everything else — especially teams migrating from Spring MVC or adding services to a primarily imperative codebase — Spring MVC with virtual threads is the simpler path to the same concurrency.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

The Backend Hiring Reality for Boston Startups That Nobody Talks About

Everyone knows Boston has a strong tech talent base. Fewer people talk about why that talent is so hard for startups to actually hire.

Read more

How to Identify Risky Software Projects Before You Start

Not all software projects are worth pursuing. Some are risky from the start, and spotting the danger early can save time, money, and frustration.

Read more

Why AI Doesn’t Replace the Judgment of a Tech Lead

AI can generate code, suggest patterns, and even review pull requests. But it cannot replace the nuanced judgment a human tech lead brings to a team.

Read more

Handling Criticism Without Feeling Defeated

Criticism stings, even when you know it’s supposed to help. Learning to handle it without losing confidence is a superpower for any professional.

Read more