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.
JOptimize Team
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.
WebFlux wins when:
WebFlux doesn't help when:
For a standard Spring Boot CRUD service with spring-data-jpa, Spring MVC + virtual threads (Java 21) is simpler and equally performant.
// 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).
@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.
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.
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.
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 }
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); }
switchIfEmpty — an empty Mono that propagates to the HTTP layer returns 200 with an empty body, not 404; always handle the empty caseblock() outside of tests — calling .block() in production code defeats the non-blocking model and risks deadlock on the event loop threadmaxInMemorySize on WebClient — the default 256KB buffer limit causes DataBufferLimitException for larger response bodiesSpring 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.
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.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.