Back to Blog
spring-bootwebfluxreactiveperformancejavaproject-reactor

Spring WebFlux: When to Go Reactive (and When Not To) — 2026 Guide

Spring WebFlux promises 10x more throughput on the same hardware. The reality is more nuanced. Learn when reactive actually helps, the programming model, and the pitfalls that kill performance.

J

JOptimize Team

May 25, 2026· 10 min read

Spring WebFlux gets recommended in two situations: when someone reads a blog post about reactive programming, and when someone has a real high-concurrency I/O problem. These are very different use cases. Used correctly, WebFlux handles 10× more concurrent connections than Spring MVC on the same thread count. Used incorrectly, it adds complexity with no performance gain — or worse, degrades performance.


When WebFlux Helps (and When It Doesn't)

WebFlux wins when:

  • Your service makes many parallel I/O calls (external APIs, DB, messaging)
  • You need high concurrency (10K+ simultaneous connections) with low thread count
  • You're building a gateway/proxy that aggregates multiple upstream services
  • You're working with streaming data (server-sent events, reactive databases)

WebFlux doesn't help when:

  • Your service is CPU-bound (computation doesn't benefit from reactive I/O)
  • You use blocking JDBC (calling a blocking driver from a reactive context deadlocks)
  • Your team isn't familiar with the reactive programming model (debugging is hard)
  • You have simple CRUD with low concurrency

For a standard Spring Boot CRUD service with spring-data-jpa, Spring MVC + virtual threads (Java 21) is simpler and equally performant.


The Core Model: Mono and Flux

// Mono<T> — 0 or 1 value (like Optional, but async) Mono<User> userMono = userRepository.findById(userId); // Returns immediately, doesn't block // Flux<T> — 0 to N values (stream) Flux<Order> ordersFlux = orderRepository.findByUserId(userId); // Nothing executes until you subscribe: userMono .flatMap(user -> orderRepository.findByUserId(user.getId())) .collectList() .subscribe(orders -> log.info("Orders: {}", orders.size()));

This is the key mental shift: you're building a pipeline, not executing code. The actual execution happens when something subscribes (the HTTP framework subscribes automatically for controllers).


WebFlux Controller

@RestController @RequiredArgsConstructor public class UserController { private final ReactiveUserRepository userRepository; private final ReactiveOrderRepository orderRepository; @GetMapping("/users/{id}") public Mono<UserDto> getUser(@PathVariable Long id) { return userRepository.findById(id) .map(UserMapper::toDto) .switchIfEmpty(Mono.error(new UserNotFoundException(id))); } // Parallel fetch — both queries run concurrently @GetMapping("/users/{id}/dashboard") public Mono<DashboardDto> getDashboard(@PathVariable Long id) { Mono<User> userMono = userRepository.findById(id); Mono<List<Order>> ordersMono = orderRepository.findByUserId(id).collectList(); return Mono.zip(userMono, ordersMono, (user, orders) -> new DashboardDto(user, orders)); } // Streaming response — push data as it arrives @GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<OrderEvent>> streamEvents() { return orderEventStream .map(event -> ServerSentEvent.builder(event).build()) .delayElements(Duration.ofMillis(100)); } }

The Mono.zip() pattern is where WebFlux genuinely shines — both queries execute in parallel on the event loop thread without blocking.


The Biggest Trap: Blocking Inside Reactive

Calling any blocking operation inside a reactive pipeline is the most common WebFlux mistake. It blocks the event loop thread, which is shared across all requests:

// DANGEROUS — blocks the event loop @GetMapping("/users/{id}") public Mono<User> getUser(@PathVariable Long id) { return Mono.just(userRepository.findById(id).get()); // BLOCKING JDBC CALL } // DANGEROUS — Thread.sleep in reactive pipeline public Mono<String> fetchWithRetry(String url) { return webClient.get().uri(url).retrieve().bodyToMono(String.class) .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1))); // OK // But don't Thread.sleep() inside any lambda here }

Fix: run blocking operations on a dedicated bounded scheduler:

@GetMapping("/users/{id}") public Mono<User> getUser(@PathVariable Long id) { return Mono.fromCallable(() -> userJdbcRepo.findById(id)) // Blocking call .subscribeOn(Schedulers.boundedElastic()); // Runs on a separate thread pool }

Schedulers.boundedElastic() is designed for blocking I/O — it has an expandable thread pool that won't exhaust the event loop.


Reactive Database: R2DBC

To get full non-blocking behavior with SQL databases, replace JDBC with R2DBC:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-r2dbc</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>r2dbc-postgresql</artifactId> </dependency>
// R2DBC reactive repository public interface ReactiveUserRepository extends ReactiveCrudRepository<User, Long> { Flux<User> findByEmail(String email); } // application.properties spring.r2dbc.url=r2dbc:postgresql://localhost:5432/mydb spring.r2dbc.username=postgres spring.r2dbc.password=password

R2DBC gives you truly non-blocking DB queries. The downside: no JPA, no lazy loading, no JPQL — you write native SQL or use query methods.


Error Handling

public Mono<UserDto> getUser(Long id) { return userRepository.findById(id) .switchIfEmpty(Mono.error(new UserNotFoundException(id))) // Empty → 404 .map(UserMapper::toDto) .onErrorMap(DataAccessException.class, // DB error → 503 ex -> new ServiceUnavailableException("Database unavailable")) .onErrorReturn(TimeoutException.class, UserDto.FALLBACK); // Timeout → fallback }

WebClient — Non-Blocking HTTP

With WebFlux, use WebClient instead of RestTemplate or RestClient:

@Configuration public class WebClientConfig { @Bean public WebClient webClient() { return WebClient.builder() .baseUrl("https://api.example.com") .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .codecs(config -> config.defaultCodecs().maxInMemorySize(2 * 1024 * 1024)) .build(); } } // Usage: fully non-blocking HTTP call public Mono<PriceDto> fetchPrice(Long productId) { return webClient.get() .uri("/prices/{id}", productId) .retrieve() .onStatus(HttpStatus::is4xxClientError, resp -> Mono.error(new ProductNotFoundException(productId))) .bodyToMono(PriceDto.class) .timeout(Duration.ofSeconds(2)) .onErrorReturn(TimeoutException.class, PriceDto.UNAVAILABLE); }

Common Mistakes to Avoid

  • Mixing blocking JDBC with reactive controllers — any blocking call in the reactive pipeline blocks the event loop and eliminates all performance benefits
  • Not handling switchIfEmpty — an empty Mono that propagates to the HTTP layer returns 200 with an empty body, not 404; always handle the empty case
  • Using block() outside of tests — calling .block() in production code defeats the non-blocking model and risks deadlock on the event loop thread
  • Not setting maxInMemorySize on WebClient — the default 256KB buffer limit causes DataBufferLimitException for larger response bodies

Summary

Spring WebFlux improves throughput for I/O-heavy services with high concurrency — specifically when you're making parallel calls to external services, streaming data, or handling 10K+ concurrent connections. For standard CRUD with JPA, Spring MVC + Java 21 virtual threads is simpler and equally fast. When you do use WebFlux, the critical rules are: never block the event loop, always handle empty Mono cases, and use R2DBC for non-blocking DB access.


Detect Blocking Calls in Reactive Code

JOptimize detects blocking JDBC calls inside WebFlux handlers, .block() calls in non-test code, and missing subscribeOn(Schedulers.boundedElastic()) for blocking operations.

Build reactive systems that actually stay non-blocking — free scan, no configuration required.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.