skip to content

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?

level: principalimportance: should knowfreq 35%

answer

  1. switchIfEmpty -> 404/400 for empty Mono
  2. onErrorResume -> map exceptions to responses
  3. centralize in HandlerFilterFunction / WebExceptionHandler
  4. never .block() on event-loop; boundedElastic for blocking
  5. return the composed Mono; no ThreadLocals — use Reactor Context

basics

~10 s

Keep 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 s

Since 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 lines
java
import 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

for a junior

Know switchIfEmpty for 404 and onErrorResume for error responses; never block.

for a middle

Explain why an empty Mono skips bodyValue and how onErrorResume maps exceptions per handler.

for a senior

Centralize error mapping in a router filter or WebExceptionHandler and offload blocking work to boundedElastic.

for a principal

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

context