Reactive Programming with Spring WebFlux
WebFlux is the non-blocking web stack of Spring. It handles many concurrent connections with a small number of threads, using Project Reactor types Mono (0 or 1 element) and Flux (0 to N elements). Understand when it helps and when virtual threads are simpler.
Thread per request vs event loop
SPRING MVC (blocking) SPRING WEBFLUX (non-blocking)
request 1 ─► thread 1 ── waits for DB ──► request 1 ┐
request 2 ─► thread 2 ── waits for API ─► request 2 ├─► few event-loop threads (about CPU count)
request 3 ─► thread 3 ── waits ─────────► request 3 ┘ never wait: they register a callback and move on
1000 waiting requests = 1000 threads 1000 waiting requests = still a few threadsMono and Flux
A Mono or Flux is a description of work (a pipeline). Nothing happens until someone subscribes. WebFlux subscribes for you when you return the Mono or Flux from a controller.
Mono<String> one = Mono.just("Hello");
Flux<Integer> many = Flux.range(1, 5);
Flux<String> result = Flux.just("ram", "sita", "amit")
.filter(n -> n.length() > 3)
.map(String::toUpperCase)
.doOnNext(n -> System.out.println("processing " + n)); // nothing printed yet
result.subscribe(System.out::println); // now it runs: SITA, AMIT| Operator | Meaning |
|---|---|
| map | Transform each element (synchronous) |
| flatMap | Transform each element into a Mono or Flux (asynchronous), and merge the results |
| filter, take, skip | Select elements |
| zip / Mono.zip | Combine results of independent calls (run in parallel) |
| onErrorResume, onErrorReturn | Recover from errors |
| switchIfEmpty | Provide an alternative when empty |
| timeout, retryWhen | Resilience |
| collectList | Turn a Flux into a Mono of a list |
A WebFlux controller with WebClient
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>@RestController
@RequestMapping("/api/dashboard")
public class DashboardController {
private final WebClient users = WebClient.create("http://user-service");
private final WebClient orders = WebClient.create("http://order-service");
public record Dashboard(User user, List<Order> orders) { }
@GetMapping("/{userId}")
public Mono<Dashboard> dashboard(@PathVariable String userId) {
Mono<User> user = users.get().uri("/users/{id}", userId).retrieve().bodyToMono(User.class);
Mono<List<Order>> list = orders.get().uri("/orders?user={id}", userId)
.retrieve().bodyToFlux(Order.class).collectList();
return Mono.zip(user, list) // both calls run at the same time
.map(t -> new Dashboard(t.getT1(), t.getT2()))
.timeout(Duration.ofSeconds(3))
.onErrorResume(TimeoutException.class, e -> Mono.error(new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT)));
}
@GetMapping(value = "/ticker", produces = MediaType.TEXT_EVENT_STREAM_VALUE) // Server-Sent Events
public Flux<String> ticker() {
return Flux.interval(Duration.ofSeconds(1)).map(i -> "tick " + i);
}
}Functional endpoints (alternative to annotations)
@Configuration
public class Routes {
@Bean
RouterFunction<ServerResponse> routes() {
return RouterFunctions.route()
.GET("/hello/{name}", req -> ServerResponse.ok().bodyValue("Hello " + req.pathVariable("name")))
.build();
}
}Testing with StepVerifier
@Test
void upperCase() {
Flux<String> flux = Flux.just("a", "b").map(String::toUpperCase);
StepVerifier.create(flux).expectNext("A").expectNext("B").verifyComplete();
}Rules of reactive code
- Never block inside a reactive pipeline (Thread.sleep, JDBC, block()). Blocking an event-loop thread freezes many requests. If you must call blocking code, wrap it with Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()).
- Use reactive drivers end to end: R2DBC (SQL), reactive MongoDB, reactive Redis. JPA and JDBC are blocking.
- Errors travel as signals. A thrown exception inside map becomes an error signal, not a normal throw.
- Debugging is harder. Use Hooks.onOperatorDebug() while developing or checkpoint().
WebFlux or virtual threads?
| Situation | Choice |
|---|---|
| Normal CRUD with JDBC or JPA | Spring MVC with virtual threads (simple code, scales well) |
| Streaming, backpressure, SSE, very many open connections | WebFlux |
| API gateway that only forwards requests (Spring Cloud Gateway) | WebFlux (it is built on it) |
| Team new to reactive, blocking libraries | MVC. Reactive has a learning cost |
Common mistake: Calling a blocking database or HTTP client inside a WebFlux pipeline. It blocks the few event-loop threads, and the whole service becomes slower than a normal MVC application. Stay non-blocking end to end, or use MVC.
Interview questions
What is backpressure?
The consumer tells the producer how many elements it can handle (request(n)), so a fast producer does not overflow a slow consumer. Reactive Streams defines this.
map or flatMap?
map is for a simple synchronous transformation. flatMap is when the transformation itself returns a Mono or Flux (for example another remote call).
Why does nothing happen if I do not subscribe?
Reactive pipelines are lazy. They describe work and execute only when subscribed. WebFlux subscribes when you return the value from a controller.