Spring WebFlux: Reactive Controllers & Functional Endpoints
Objective
Spring WebFlux is the reactive counterpart to Spring MVC: the same annotation-driven
programming model — @RestController, @RequestMapping, @GetMapping,
@RequestBody — but with handler methods that accept and return Mono and Flux
(see Reactor Fundamentals for what those types are and
what the Reactive Streams contract guarantees) instead of blocking domain types and
collections. It is a separate framework rather than a reactive mode bolted onto
Spring MVC, because Spring MVC is built on the Servlet API, whose core contracts are
synchronous or outright blocking; WebFlux sits on a reactive HTTP abstraction instead
and therefore needs no servlet container at all — Spring Boot runs it on Netty by
default. On top of that, WebFlux ships a second, entirely annotation-free programming
model: functional endpoints, where a RouterFunction bean maps request predicates to
HandlerFunctions in plain code. This concept covers the server side — controllers,
routers, and how to test both. The client half of the reactive web story lives in
Reactive Consumption with WebClient.
Use Cases
- A high-concurrency HTTP API — tens of thousands of mostly-idle connections, IoT devices, long polling, SSE — where the thread-per-request model would exhaust the servlet container's pool long before the CPU is busy.
- An aggregating endpoint that fans out to several downstream services and combines the results, where the composition itself (merge, zip, timeout, retry, take-first) is the hard part and no thread should be parked waiting on any one call.
- Streaming a response that is large or unbounded — a live feed, a tail of events, a multi-million-row export — where the client's demand, not the server's memory, should govern how fast data flows.
- A lightweight, functional-style API (a small microservice, a gateway, a set of webhook receivers) where the annotation machinery buys little and explicit routing code buys transparency and breakpoints.
- Any application already committed to a reactive stack end to end — R2DBC or reactive
Mongo repositories,
WebClientcalls, RSocket — where the web layer must speakMono/Fluxnatively or the whole model collapses.
Deep Dive
Two stacks, one set of annotations
Spring MVC sits on the Servlet API and assumes a request may block: the container
keeps a large thread pool so that a parked thread is merely wasteful, not fatal.
WebFlux assumes the opposite — that application code never blocks — and so runs on a
small, fixed-size pool of event-loop workers, typically one per CPU core. What makes
WebFlux approachable is that the top of the two stacks is shared: the annotations
that define a controller come from spring-web and are identical on both sides.
The most consequential decision is made in the build file, not in the code — which starter you pull in selects which stack you get:
plaintext<!-- Spring MVC: servlet stack, Tomcat by default --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Spring WebFlux: reactive stack, Netty by default --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>
Add both and Spring Boot picks Spring MVC — a common surprise when a WebFlux service
transitively drags in spring-boot-starter-web and quietly starts on Tomcat with a
servlet dispatcher.
Annotated reactive controllers
A Spring MVC controller that returns a collection is doing two blocking things: the repository call blocks until the rows are in memory, and the handler cannot return until it has them.
java@RestController
@RequestMapping(path = "/design", produces = "application/json")
public class DesignTacoController {
private final TacoRepository tacoRepo;
public DesignTacoController(TacoRepository tacoRepo) {
this.tacoRepo = tacoRepo;
}
@GetMapping("/recent")
public Iterable<Taco> recentTacos() {
PageRequest page = PageRequest.of(0, 12, Sort.by("createdAt").descending());
return tacoRepo.findAll(page).getContent();
}
}Iterable is not a reactive type: no operators apply to it, and the framework cannot
treat it as a stream. The minimal step is to adapt at the controller boundary —
useful when the repository underneath is still blocking:
java@GetMapping("/recent")
public Flux<Taco> recentTacos() {
return Flux.fromIterable(tacoRepo.findAll()).take(12);
}This is honest about the shape of the response but dishonest about the execution:
tacoRepo.findAll() still blocks whatever thread calls it, and on WebFlux that is an
event-loop thread. The real target is a repository that hands back a Flux in the
first place, so the controller is the tip of an end-to-end reactive stack:
javapublic interface TacoRepository extends ReactiveCrudRepository<Taco, Long> {
}
@GetMapping("/recent")
public Flux<Taco> recentTacos() {
return tacoRepo.findAll().take(12);
}Note what is not there: no subscribe(). The framework subscribes to the returned
publisher when it writes the response. The handler method returns before a single row
has been fetched — it has returned a description of the work, not the work's result.
Single-valued responses collapse in the same way. The imperative version has to
unwrap an Optional by hand:
java@GetMapping("/{id}")
public Taco tacoById(@PathVariable("id") Long id) {
Optional<Taco> optTaco = tacoRepo.findById(id);
if (optTaco.isPresent()) {
return optTaco.get();
}
return null;
}A reactive repository returns Mono<Taco>, which already encodes "zero or one", so
the branching disappears:
java@GetMapping("/{id}")
public Mono<Taco> tacoById(@PathVariable("id") Long id) {
return tacoRepo.findById(id);
}An empty Mono becomes a 404 without an if in sight. Reactor types are the natural
choice, but WebFlux is not tied to them: RxJava's Observable, Flowable, Single,
and Completable (equivalent to Mono<Void>) all work as return types, and Kotlin
coroutines' suspend functions and Flow are first-class as well.
Accepting reactive input
Return types are only half of it. A handler that binds @RequestBody to a domain
object cannot be invoked until the entire request payload has been read and
deserialized — so the request blocks on the way in as well as on the way out:
java@PostMapping(consumes = "application/json")
@ResponseStatus(HttpStatus.CREATED)
public Taco postTaco(@RequestBody Taco taco) {
return tacoRepo.save(taco);
}Declaring the body as a Mono<Taco> makes the method callable immediately, before
the body has arrived. It is handed a publisher of the eventual payload and returns a
publisher of the eventual response:
java@PostMapping(consumes = "application/json")
@ResponseStatus(HttpStatus.CREATED)
public Mono<Taco> postTaco(@RequestBody Mono<Taco> tacoMono) {
return tacoRepo.saveAll(tacoMono).next();
}saveAll() on a reactive repository accepts any Reactive Streams Publisher, so it
takes the Mono directly and returns a Flux<Taco>. Because the source was a Mono,
that Flux emits at most one element, and next() narrows it back to Mono<Taco>.
Nothing in this method waits for anything: the whole request/save/respond chain is
assembled and returned before the first byte of the body is parsed. Reactive
@RequestBody arguments are the one place where WebFlux's annotated model genuinely
diverges from Spring MVC's — Spring MVC controllers may return Mono/Flux, but
they cannot accept a reactive request body.
Functional endpoints: RouterFunction and HandlerFunction
Annotations split what from how: the annotation declares intent, the framework decides behaviour somewhere else, and you cannot set a breakpoint on an annotation. WebFlux's functional model removes the indirection — the application routes and handles requests itself, in ordinary code, using four types:
RequestPredicate— declares which requests match.RouterFunction— maps a matching request to a handler. The reference defines it as "a function that takesServerRequestand returns a delayedHandlerFunction".ServerRequest/ServerResponse— immutable views of the HTTP exchange, with reactive body extraction (bodyToMono,bodyToFlux) and reactive body writing.
A HandlerFunction is simply "a function that takes a ServerRequest and returns a
delayed ServerResponse (i.e. Mono<ServerResponse>)". The whole thing is a @Bean:
javaimport static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
import static org.springframework.web.reactive.function.server.ServerResponse.ok;
import static reactor.core.publisher.Mono.just;
@Configuration
public class RouterFunctionConfig {
@Bean
public RouterFunction<ServerResponse> helloRouterFunction() {
return route(GET("/hello"),
request -> ok().body(just("Hello World!"), String.class))
.andRoute(GET("/bye"),
request -> ok().body(just("See ya!"), String.class));
}
}Lambdas are fine while the handler is one expression. Anything real belongs in a method (or a dedicated handler class), referenced by method reference — which is also where the breakpoint goes:
java@Configuration
public class RouterFunctionConfig {
@Bean
public RouterFunction<ServerResponse> routerFunction(TacoRepository tacoRepo) {
return RouterFunctions.route()
.GET("/design/taco", request -> recents(request, tacoRepo))
.POST("/design", request -> postTaco(request, tacoRepo))
.build();
}
private Mono<ServerResponse> recents(ServerRequest request, TacoRepository tacoRepo) {
return ServerResponse.ok()
.body(tacoRepo.findAll().take(12), Taco.class);
}
private Mono<ServerResponse> postTaco(ServerRequest request, TacoRepository tacoRepo) {
return request.bodyToMono(Taco.class)
.flatMap(tacoRepo::save)
.flatMap(saved -> ServerResponse
.created(URI.create("/design/taco/" + saved.getId()))
.bodyValue(saved));
}
}Two details are worth dwelling on. First, body(publisher, Class) writes an
unresolved publisher — the response starts streaming as rows arrive — whereas
bodyValue(obj) writes a value already in hand. Second, postTaco has to compose:
the saved taco's id is inside a Mono, so building a Location header from it
requires flatMap, not a getter. Reaching into a publisher for a field is the single
most common mistake when translating an imperative handler, and it does not compile.
Routes nest, which is where the builder earns its keep on a real API:
java@Bean
public RouterFunction<ServerResponse> tacoRoutes(TacoHandler handler) {
return RouterFunctions.route()
.path("/design", builder -> builder
.GET("/recent", accept(APPLICATION_JSON), handler::recents)
.GET("/{id}", accept(APPLICATION_JSON), handler::byId)
.POST("", handler::postTaco))
.build();
}Nothing stops an application from running both models side by side; annotated controllers and router function beans coexist in the same context.
Testing with WebTestClient — mock environment
WebTestClient is the reactive analogue of MockMvc/TestRestTemplate: a fluent
HTTP client with assertions built in, which can drive a controller through mock
request and response objects with no server running at all. Binding directly to a
controller instance keeps the test a unit test:
javapublic class DesignTacoControllerTest {
@Test
public void shouldReturnRecentTacos() {
Taco[] tacos = {
testTaco(1L), testTaco(2L), testTaco(3L), testTaco(4L),
testTaco(5L), testTaco(6L), testTaco(7L), testTaco(8L),
testTaco(9L), testTaco(10L), testTaco(11L), testTaco(12L),
testTaco(13L), testTaco(14L), testTaco(15L), testTaco(16L) };
Flux<Taco> tacoFlux = Flux.just(tacos);
TacoRepository tacoRepo = Mockito.mock(TacoRepository.class);
when(tacoRepo.findAll()).thenReturn(tacoFlux);
WebTestClient testClient = WebTestClient
.bindToController(new DesignTacoController(tacoRepo))
.build();
testClient.get().uri("/design/recent")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$").isArray()
.jsonPath("$").isNotEmpty()
.jsonPath("$[0].name").isEqualTo("Taco 1")
.jsonPath("$[11].name").isEqualTo("Taco 12")
.jsonPath("$[12]").doesNotExist();
}
}exchange() is the point where the request is actually submitted; everything before
it describes the request and everything after it asserts on the response. The mock
repository publishes 16 tacos and the last assertion pins the contract that matters —
take(12) really did truncate.
Long chains of jsonPath() get unreadable fast. Two alternatives: compare the whole
body against a JSON document on the classpath, or assert on a typed list.
javaClassPathResource recentsResource = new ClassPathResource("/tacos/recent-tacos.json");
String recentsJson = StreamUtils.copyToString(
recentsResource.getInputStream(), Charset.defaultCharset());
testClient.get().uri("/design/recent")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.json(recentsJson);
testClient.get().uri("/design/recent")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBodyList(Taco.class)
.contains(Arrays.copyOf(tacos, 12));Every HTTP method has a corresponding builder method — get(), post(), put(),
patch(), delete(), head() — and a request with a body takes a publisher, so the
input side stays reactive too:
java@Test
public void shouldSaveATaco() {
TacoRepository tacoRepo = Mockito.mock(TacoRepository.class);
Mono<Taco> unsavedTacoMono = Mono.just(testTaco(null));
Taco savedTaco = testTaco(null);
savedTaco.setId(1L);
when(tacoRepo.save(any())).thenReturn(Mono.just(savedTaco));
WebTestClient testClient = WebTestClient
.bindToController(new DesignTacoController(tacoRepo))
.build();
testClient.post()
.uri("/design")
.contentType(MediaType.APPLICATION_JSON)
.body(unsavedTacoMono, Taco.class)
.exchange()
.expectStatus().isCreated()
.expectBody(Taco.class)
.isEqualTo(savedTaco);
}Functional endpoints are testable the same way — there is no controller instance to bind to, so bind to the router instead:
javaRouterFunction<ServerResponse> route = new RouterFunctionConfig().routerFunction(tacoRepo);
WebTestClient testClient = WebTestClient.bindToRouterFunction(route).build();Testing against a live server
Mock bindings exercise the framework, not the server. To test a controller inside a
real Netty (or Tomcat) instance, with the real repository and the real serialization
path, ask Spring Boot to start one on a random port and inject a WebTestClient
already pointed at it:
java@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class DesignTacoControllerWebTest {
@Autowired
private WebTestClient testClient;
@Test
public void shouldReturnRecentTacos() {
testClient.get().uri("/design/recent")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$[?(@.id == 'TACO1')].name").isEqualTo("Carnivore")
.jsonPath("$[?(@.id == 'TACO2')].name").isEqualTo("Bovine Bounty")
.jsonPath("$[?(@.id == 'TACO3')].name").isEqualTo("Veg-Out");
}
}The injected client knows the randomly chosen port, so URIs stay relative. Prefer
RANDOM_PORT over DEFINED_PORT — a fixed port invites a clash with a concurrently
running server or another test class. For a client pointed at something Spring did not
start, WebTestClient.bindToServer().baseUrl("http://localhost:8080").build() covers
the fully external case.
Book vs. today. The core of this chapter has aged unusually well. WebFlux's annotated model, the functional endpoint model, and
WebTestClientare all materially unchanged from 2019 through Spring Framework 6.x/7.x: the reference guide still describes annotated controllers as "consistent with Spring MVC and based on the same annotations from thespring-webmodule", still defines aHandlerFunctionas "a function that takes aServerRequestand returns a delayedServerResponse", and still confirms that "Spring Boot defaults to Netty".RouterFunctions.route(predicate, handler)and.andRoute(...)from the book are not deprecated; Spring 5.1 simply added the discoverableroute()builder (.GET(...),.POST(...),.path(...),.nest(...),.build()) shown above, which is what current docs lead with. The genuinely stale bits are small: JUnit 4's@RunWith(SpringRunner.class)is unnecessary under JUnit 5 (@SpringBootTestbootstraps the context by itself),syncBody()was renamedbodyValue()in Spring 5.2, and Boot 3 movedjavax.*tojakarta.*. Two of the book's listings also don't compile as printed — the functionalpostTaco()callssavedTaco.getId()on aMono<Taco>and passes aMonotosave(); the versions above fix both withflatMap.What has changed is the decision the chapter implicitly makes for you. In 2019, WebFlux was the only mainstream Java answer to "serve tens of thousands of concurrent I/O-bound requests without a thread each". Java 21 virtual threads changed that, and Spring now says so explicitly: its "Runtime efficiency with Spring" post states that "Virtual Threads make blocking on I/O cheap and are therefore an ideal fit for Spring Web MVC applications on a Servlet stack", and expects virtual threads plus Spring MVC (
spring.threads.virtual.enabled=true) to become the common choice on Java 21+ for typical web workloads — especially, per the reference guide, when "you have blocking persistence APIs (JPA, JDBC) or networking APIs to use". WebFlux's remaining unique value, in Spring's own framing, is application-level concurrency and streaming: sending multiple remote requests and combining the results, backpressure over unbounded streams, SSE/WebSocket/RSocket. The reference guide is also blunt that reactive "generally do[es] not make applications run faster" — "the key expected benefit ... is the ability to scale with a small, fixed number of threads and less memory". Read this chapter as a toolkit for those cases, not as the default answer to "my API is slow under load".
Trade-offs
- Annotated controllers vs. functional endpoints — familiarity against
transparency. Annotations are what every Spring developer already knows, and
migrating a Spring MVC controller often means changing only the return type.
Functional endpoints put the application in charge from start to finish: routing is
ordinary code you can read, compose, unit-test, and set a breakpoint inside, and
routes can be assembled conditionally. The cost is that everything the annotations
did for free — content negotiation predicates,
@Valid, exception handler resolution, argument binding — becomes something you write explicitly.java// annotated: the framework calls you @GetMapping("/{id}") public Mono<Taco> tacoById(@PathVariable Long id) { return tacoRepo.findById(id); } // functional: you call the framework .GET("/{id}", req -> tacoRepo.findById(Long.valueOf(req.pathVariable("id"))) .flatMap(t -> ok().bodyValue(t)) .switchIfEmpty(ServerResponse.notFound().build())) - Netty by default changes the operational picture, not just the code. A WebFlux
service has no servlet container, so servlet
Filters,HandlerInterceptors,ServletContexttricks, servlet-based metrics and access-log configuration, and any library that reaches forHttpServletRequestsimply do not apply — the equivalents areWebFilterandServerWebExchange. Thread pool tuning inverts too: instead of sizing a large worker pool you have a handful of event-loop threads, and the usual "raiseserver.tomcat.threads.max" lever does not exist. - "Reactive all the way down" now includes the whole controller layer. One blocking
call inside a handler occupies an event-loop thread, of which there are roughly one
per core, and stalls every other in-flight request that thread was multiplexing —
a failure mode with no equivalent on the servlet stack, where blocking one worker
out of two hundred is merely wasteful:
Adopting WebFlux therefore pulls in R2DBC or a reactive driver,java@GetMapping("/recent") public Flux<Taco> recentTacos() { // blocking JDBC on a Netty event-loop thread: stalls unrelated requests return Flux.fromIterable(jdbcTacoRepo.findAll()).take(12); }WebClientinstead ofRestTemplate, and reactive equivalents of every other integration — or explicitsubscribeOn(Schedulers.boundedElastic())offloading, which works but reintroduces a thread per blocking call. - Virtual threads have narrowed the case for adopting WebFlux at all. For a
conventional request/response service that is slow only because it waits on I/O,
Java 21+ virtual threads with
spring.threads.virtual.enabled=trueon Spring MVC deliver comparable thread efficiency while keeping imperative code, real stack traces, working thread-locals, and ordinary debuggers and profilers. Spring's own guidance now points there first for typical web workloads and reserves WebFlux for application-level concurrency, streaming, and backpressure. Choosing WebFlux today should be justified by what reactive composes, not by scalability alone. - Handler methods return before the work happens, which inverts error handling and
observability. Because the framework subscribes, exceptions thrown inside the
pipeline surface as
onErrorsignals rather than as something a surroundingtry/catchin the handler can see, and stack traces show Reactor internals rather than your call path.@ExceptionHandlerstill works, butonErrorResume,checkpoint(), andHooks.onOperatorDebug()become part of the everyday toolkit. WebTestClientcovers unit and integration testing well, but mock bindings are not the server.bindToController/bindToRouterFunctionrun against mock request and response objects — fast, and enough for routing, serialization, and status codes, but they never exercise Netty, real connection handling, backpressure, or codec configuration that only appears at runtime. A@SpringBootTest(webEnvironment = RANDOM_PORT)layer on top is what catches those, at the cost of a real context per test class.
Documentation Links
- Craig Walls, "Spring in Action", 5th Edition (Manning, 2019) — Chapter 11, "Developing reactive APIs", sections 11.1-11.3, p. 269-284
- Spring Framework Reference — WebFlux Annotated Controllers
- Spring Framework Reference — WebFlux Functional Endpoints (RouterFunction, HandlerFunction)
- Spring Framework Reference — Testing with WebTestClient
- Spring Framework Reference — Spring WebFlux Overview (why WebFlux, servers, WebFlux vs Spring MVC)
- Spring Blog — Runtime efficiency with Spring (virtual threads vs. the reactive stack)