WebFlux is the reactive alternative to Spring MVC, built on top of Project Reactor. With the arrival of virtual threads in Java 21, the argument "we need a lot of RPS on a single node, so let's use WebFlux" has weakened considerably: plain MVC + virtual threads often solves the same problem more simply. That's why this article starts with the honest part — when WebFlux is really justified — and ends with the practical side of using it.
When WebFlux is justified
| Scenario | WebFlux | Alternative |
|---|---|---|
| Many parallel I/O calls in one request (5+ HTTP clients) | yes, a natural fit | MVC + CompletableFuture |
| Streaming response (SSE, server push, chunked delivery) | yes | MVC supports SSE, but less idiomatically |
| Long polling, WebSocket with thousands of connections | yes | MVC + Tomcat NIO works, but less efficiently |
| Backpressure from a downstream service | yes, native support | manually via semaphores |
| The team already writes in Reactor style in other services | yes | — |
| Just "a lot of RPS on CRUD" | no | MVC + Java 21 virtual threads |
| Spring Data repositories, JPA, blocking drivers | no (would block the event loop) | MVC |
| The team doesn't know reactive programming | no | MVC |
The main practical rule: WebFlux requires the entire stack to be non-blocking. If there's a single JdbcTemplate.queryForObject(...) somewhere, the event loop stalls, the advantage disappears, and the complexity stays.
Mono and Flux
The two core Project Reactor types:
Mono<T>— 0 or 1 element (the async equivalent ofOptional<T>+Future<T>).Flux<T>— 0..N elements (an async stream).
Both are cold by default: computation begins only when someone subscribes (subscribe() or another terminal operator). WebFlux subscribes on its own when you return a Mono/Flux from a controller.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderRepository repo;
private final PricingClient pricing;
@GetMapping("/{id}")
public Mono<OrderResponse> get(@PathVariable UUID id) {
return repo.findById(id)
.flatMap(order -> pricing.calculate(order) // I/O call to the pricing service
.map(price -> OrderResponse.from(order, price)))
.switchIfEmpty(Mono.error(new OrderNotFoundException(id)));
}
@GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<OrderEvent> stream() {
return repo.streamEvents(); // SSE, an infinite stream of events
}
}
Operators — three categories
- Transformation:
map,flatMap,concatMap,zip. - Filtering / selection:
filter,take,skip,distinct. - Error handling:
onErrorReturn,onErrorResume,retry,timeout. - Schedulers:
subscribeOn,publishOn— which pool to run on.
flatMap vs concatMap is a common source of confusion: flatMap parallelizes, concatMap preserves order. If order matters — use concatMap.
R2DBC — the reactive counterpart to JDBC
JDBC is blocking; it can't be used in WebFlux without a workaround (via Schedulers.boundedElastic(), but that defeats the purpose). For PostgreSQL/MySQL/MSSQL there's R2DBC — a non-blocking driver.
public interface OrderRepository extends R2dbcRepository<Order, UUID> {
@Query("SELECT * FROM orders WHERE customer_id = :customerId")
Flux<Order> findByCustomer(UUID customerId);
Mono<Order> findByOrderNumber(String orderNumber);
}
Downsides of R2DBC:
- No full JPA equivalent —
R2dbcEntityTemplateis closer to JdbcTemplate than to Hibernate. - Transactions via
TransactionalOperator, not@Transactional(though the latter also works, but with reactor-context propagation). - Fewer features than JPA: lazy loading, dirty checking, cascading — none of that exists.
In the UCP stack (hexagonal projects): R2DBC is not used on the write side (there aggregates, invariants, and dirty tracking matter), and on the read side — occasionally, when you need a reactive read pipeline for streaming.
WebClient — the reactive HTTP client
A replacement for RestTemplate (deprecated). It works in both the reactive and the blocking stack.
@Configuration
public class PricingClientConfig {
@Bean
public WebClient pricingClient(WebClient.Builder builder) {
return builder
.baseUrl("https://pricing.internal")
.defaultHeader("X-Service", "orders")
.build();
}
}
@Component
@RequiredArgsConstructor
public class PricingClient {
private final WebClient client;
public Mono<Price> calculate(Order order) {
return client.post().uri("/quote")
.bodyValue(order)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, resp -> Mono.error(new BadRequest()))
.onStatus(HttpStatusCode::is5xxServerError, resp -> Mono.error(new PricingDownException()))
.bodyToMono(Price.class)
.timeout(Duration.ofSeconds(2))
.retryWhen(Retry.backoff(3, Duration.ofMillis(200)));
}
}
Advantages of WebClient over RestTemplate:
- Non-blocking I/O (important for WebFlux).
- Declarative timeouts, retry, error mapping.
- Streaming response (SSE).
WebClient can also be used in a regular MVC service — just block the result with .block() or .toFuture().get(). That's fine.
The main pitfalls
1. .block() in a reactive chain
@GetMapping("/bad")
public Mono<String> bad() {
String result = someService.fetch().block(); // blocks the event-loop thread!
return Mono.just(result.toUpperCase());
}
The event loop in WebFlux is small (sized by the number of CPUs); a single .block() halts all request processing. Never call it in reactive code, except inside Schedulers.boundedElastic().
2. ThreadLocal doesn't work
Reactor switches threads between operators — ThreadLocal (MDC, SecurityContext) is lost. Solutions:
- Reactor Context —
.contextWrite(ctx -> ctx.put("key", value))+Mono.deferContextual. - Micrometer Context Propagation (since 1.10+) — automatically carries over MDC/SecurityContext.
@Bean
ContextRegistry contextRegistry() {
return ContextRegistry.getInstance()
.registerThreadLocalAccessor("traceId",
() -> MDC.get("traceId"),
v -> MDC.put("traceId", v),
() -> MDC.remove("traceId"));
}
3. Debugging is harder
Stack traces of reactive operations don't show where in the code the error occurred. Enable debug mode via Hooks.onOperatorDebug() (slow, dev only) or use checkpoints:
return someService.fetch()
.checkpoint("after-fetch")
.map(...)
.checkpoint("after-map")
.flatMap(...);
4. Smart bytecode instrumentation for tracing
Tracing via Micrometer + Brave/OpenTelemetry in WebFlux requires context propagation. With Spring Boot 3.2+ it works almost out of the box, but you need to verify that the baggage is carried between operators.
WebFlux vs Virtual Threads
Java 21 added virtual threads. In Spring Boot 3.2+ you can enable them via:
spring.threads.virtual.enabled=true
After that, Tomcat handles each request in a virtual thread. This gives you:
- Familiar MVC code with
JdbcTemplate,@Transactional, blockingRestTemplate/WebClient.block(). - I/O calls under the hood don't block the physical thread — the JVM uses park.
- Thousands of parallel requests on a single JVM without switching to reactive.
When WebFlux is still needed after virtual threads:
- Streaming endpoints (SSE, server push) — Reactor is native for this.
- Complex I/O compositions (5-10 parallel calls with retry/timeout/zip) — reactive operators are more expressive.
- Backpressure from a slow consumer.
- The team already knows Reactor and wants a single style.
When WebFlux is not needed after virtual threads:
- High RPS on CRUD endpoints. Virtual threads solve the same case more simply.
- The team is new to reactive programming.
Further reading
- Spring MVC — the synchronous alternative, sufficient in most cases.
@Transactionalin depth — in WebFlux, transactions go throughTransactionalOperator.- Scheduled, Async, virtual threads — more detail on virtual threads.
- Project Reactor reference — the official documentation.