In a functional WebFlux endpoint, how do you handle errors and empty results so the client gets the right status, and what threading pitfalls must you avoid?
answer
- switchIfEmpty -> 404/400 for empty Mono
- onErrorResume -> map exceptions to responses
- centralize in HandlerFilterFunction / WebExceptionHandler
- never .block() on event-loop; boundedElastic for blocking
- return the composed Mono; no ThreadLocals — use Reactor Context
basics
~10 sKeep everything in the reactive chain: use switchIfEmpty to turn an empty Mono into a 404/400 response, and onErrorResume to map exceptions to error responses. Never block; always return the composed Mono<ServerResponse>.
solid answer
~40 sSince there's no @ExceptionHandler binding by default in the functional model, you shape outcomes inside the reactive pipeline. Use switchIfEmpty(ServerResponse.notFound().build()) to convert an empty Mono (e.g. entity not found) into a proper status — because bodyValue never runs when the source emits nothing, a bare chain would otherwise produce no response. Use onErrorResume(ex -> ...) to map exceptions to responses, ideally centralized in a HandlerFilterFunction applied via the router's .filter(...) so every route shares consistent error mapping. You can also register a WebExceptionHandler / customize the default error handling. Threading: handlers run on non-blocking event-loop threads, so never call .block() or do blocking I/O inline — offload blocking work with subscribeOn(Schedulers.boundedElastic()). Preserve the reactive context; don't rely on ThreadLocals. Always return the fully-composed Mono<ServerResponse> — side effects outside the chain won't be subscribed.
code
java · 30 linesimport org.springframework.web.reactive.function.server.*;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
// Per-handler empty + error shaping
public Mono<ServerResponse> getUser(ServerRequest request) {
String id = request.pathVariable("id");
return userService.findById(id) // Mono<User> (may be empty / may error)
.flatMap(u -> ServerResponse.ok().bodyValue(u))
.switchIfEmpty(ServerResponse.notFound().build()) // empty -> 404
.onErrorResume(IllegalArgumentException.class,
e -> ServerResponse.badRequest().bodyValue(e.getMessage()));
}
// Offloading unavoidable blocking work off the event loop
public Mono<ServerResponse> legacyReport(ServerRequest request) {
return Mono.fromCallable(legacyDao::runBlockingReport) // blocking call
.subscribeOn(Schedulers.boundedElastic()) // dedicated pool
.flatMap(report -> ServerResponse.ok().bodyValue(report));
}
// Centralized error mapping as a router filter (applies to every route)
RouterFunction<ServerResponse> routes(UserHandler h) {
return RouterFunctions.route()
.GET("/users/{id}", h::getUser)
.filter((req, next) -> next.handle(req)
.onErrorResume(NotFoundException.class,
e -> ServerResponse.notFound().build()))
.build();
}go deeper
Know switchIfEmpty for 404 and onErrorResume for error responses; never block.
Explain why an empty Mono skips bodyValue and how onErrorResume maps exceptions per handler.
Centralize error mapping in a router filter or WebExceptionHandler and offload blocking work to boundedElastic.
Design layered error strategy, context propagation (Reactor Context/Micrometer), backpressure, and reason about event-loop starvation and subscription semantics.
**Why this is different in the functional model.** With annotated controllers, `@ExceptionHandler`/`@ControllerAdvice` and Spring's `ResponseStatusException` handling give you declarative error mapping. In the *functional* model there's no per-handler annotation binding, so you shape both empty and error outcomes **within the reactive pipeline** (or with router filters / a global handler). **Empty results.** A `Mono` that completes without emitting (a not-found lookup) means downstream `flatMap`/`bodyValue` operators are simply skipped — the handler would return an empty `Mono<ServerResponse>`, which yields no proper response. Fix with `switchIfEmpty`: ```java service.find(id) .flatMap(u -> ServerResponse.ok().bodyValue(u)) .switchIfEmpty(ServerResponse.notFound().build()); ``` Note `switchIfEmpty` is subscribed only when the upstream is empty; supply a `Mono<ServerResponse>` for the fallback. **Errors.** Map exceptions to responses with `onErrorResume`: ```java .onErrorResume(NotFoundException.class, e -> ServerResponse.notFound().build()) .onErrorResume(ValidationException.class, e -> ServerResponse.badRequest().bodyValue(e.getMessage())); ``` Better: centralize this in a `HandlerFilterFunction` attached via the router's `.filter((request, next) -> next.handle(request).onErrorResume(...))`, so every route shares one error-mapping policy instead of duplicating `onErrorResume` per handler. For truly global handling you can implement a `WebExceptionHandler` bean or rely on Spring Boot's default error attributes/`ErrorWebExceptionHandler` (customizable via `AbstractErrorWebExceptionHandler`). `ResponseStatusException` thrown from a handler is also translated to the corresponding status by the default handler. **Threading & reactive pitfalls (the principal-level meat).** - Handlers execute on **event-loop threads** (Reactor Netty). **Blocking them stalls the whole server** and can deadlock. Never call `.block()`, `Thread.sleep`, or blocking JDBC/HTTP inline. - For unavoidable blocking work, offload: `Mono.fromCallable(this::blockingCall).subscribeOn(Schedulers.boundedElastic())`. - **Return, don't fire-and-forget.** Any publisher not part of the returned `Mono<ServerResponse>` is never subscribed, so its side effects (a save, a log) silently never happen. Compose everything into the returned chain (`then`, `flatMap`, `doOnNext`). - **Don't rely on ThreadLocals** across async boundaries — work hops threads. Propagate values via the Reactor `Context` (`contextWrite`/`deferContextual`) or Micrometer context-propagation. Security context, MDC logging, etc. need explicit propagation. - **Backpressure**: when streaming a `Flux` body, respect demand; don't buffer unbounded upstreams into memory. - **One-shot request body**: combine with the above — read once, and route errors from decoding (`ServerWebInputException`) through the same `onErrorResume`/filter. **When to use which layer.** Per-handler `switchIfEmpty`/`onErrorResume` for endpoint-specific rules; a router `.filter(...)` for a group's shared policy; a `WebExceptionHandler`/customized `ErrorWebExceptionHandler` for application-wide fallback and consistent error bodies.
- Why might a handler that clearly calls repository.save(...) never persist anything?If the save publisher isn't composed into the returned Mono<ServerResponse>, nothing subscribes to it, so it never executes. In reactive code you must chain it (flatMap/then) into the returned pipeline — 'nothing happens until you subscribe.'
- How do you handle a genuinely blocking dependency (e.g. blocking JDBC) inside a WebFlux handler?Wrap it in Mono.fromCallable(...) and subscribeOn(Schedulers.boundedElastic()) to move it off the event-loop threads onto a bounded pool sized for blocking work, so the reactive threads stay free.
- How do you propagate MDC/security context across the async boundaries in a handler?Not via ThreadLocals (threads change). Use the Reactor Context (contextWrite/deferContextual) or Micrometer context-propagation; Spring Security's reactive support propagates the SecurityContext through the Reactor context.
saying these in an interview costs you the question
- Calling .block() inside a handler on the event loop
- Omitting switchIfEmpty so an empty Mono yields no response
- Assuming @ControllerAdvice/@ExceptionHandler binds to functional handlers by default
- Doing side effects outside the returned chain and expecting them to run
- Relying on ThreadLocals across reactive operators