skip to content

In an annotated WebFlux controller, who subscribes to the returned publisher, where is the threading danger, and how do you return proper HTTP status codes and handle errors reactively?

level: principalimportance: should knowfreq 40%

answer

  1. Framework subscribes; never .subscribe/.block
  2. Event loop is tiny — offload to boundedElastic
  3. Mono<ResponseEntity> for dynamic status
  4. Errors are onError signals: onErrorResume/Map/Return
  5. ThreadLocal dead — use Reactor Context

basics

~10 s

The framework subscribes to your Mono/Flux on the event-loop thread — so never block it. For status/errors, use ResponseEntity or ResponseStatusException, compose errors with onErrorResume/switchIfEmpty, and centralize with @ExceptionHandler; offload blocking work to boundedElastic.

solid answer

~40 s

Spring's reactive `HandlerAdapter` subscribes to whatever you return and writes emissions to the response — you must never `.subscribe()` or `.block()` yourself. That subscription runs on Reactor Netty's small event-loop pool, so any blocking call (JDBC, `.block()`, `Thread.sleep`) starves all requests; offload unavoidable blocking with `subscribeOn(Schedulers.boundedElastic())`. For HTTP semantics: return `Mono<ResponseEntity<T>>` to set status/headers dynamically, throw or `Mono.error(new ResponseStatusException(...))` for error statuses, and use `.switchIfEmpty(...)` to turn an empty result into a 404. Errors are signals, not exceptions on the stack: compose with `onErrorResume`, `onErrorReturn`, `onErrorMap`, and centralize cross-cutting handling with `@ExceptionHandler` methods (or a `@ControllerAdvice`) that themselves return `Mono`/`Flux`. Because subscription is deferred, ThreadLocal-based context (security, MDC) must ride the Reactor `Context`, not `ThreadLocal`.

code

java · 30 lines
java
@RestController
@RequestMapping("/users")
class UserController {
    private final UserService service;
    UserController(UserService service) { this.service = service; }

    @GetMapping("/{id}")
    Mono<ResponseEntity<User>> byId(@PathVariable String id) {
        return service.find(id)                 // Mono<User>, may be empty
            .map(ResponseEntity::ok)
            .defaultIfEmpty(ResponseEntity.notFound().build())
            .onErrorResume(DataAccessException.class,
                ex -> Mono.error(new ResponseStatusException(
                        HttpStatus.SERVICE_UNAVAILABLE, "db down", ex)));
    }

    // isolate an unavoidable blocking call off the event loop
    @GetMapping("/{id}/legacy")
    Mono<String> legacy(@PathVariable String id) {
        return Mono.fromCallable(() -> blockingLookup(id))
                   .subscribeOn(Schedulers.boundedElastic());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    Mono<ResponseEntity<String>> badInput(IllegalArgumentException ex) {
        return Mono.just(ResponseEntity.badRequest().body(ex.getMessage()));
    }

    private String blockingLookup(String id) { /* JDBC etc. */ return id; }
}

go deeper

for a junior

Know the framework subscribes and you must not block the event loop.

for a middle

Use ResponseStatusException/ResponseEntity for status and onErrorResume for fallbacks.

for a senior

Offload blocking to boundedElastic, centralize with @ExceptionHandler returning Mono, map empty to 404.

for a principal

Reason about Reactor Context propagation for security/MDC, subscription laziness pitfalls, and when reactive isn't worth it versus blocking/virtual-thread MVC.

## 1. Who subscribes — and why it matters A Reactor `Mono`/`Flux` is **lazy**: nothing happens until subscribed. In an annotated WebFlux controller, the reactive `HandlerAdapter`/`ResponseBodyResultHandler` **subscribes for you** and pipes emitted elements into the HTTP response body. Consequences: - **Never call `.subscribe()` yourself** — you'd trigger the work off-band and the framework still needs the publisher to write the response. - **Never call `.block()`** — it converts the async pipeline into a blocking wait and can throw `IllegalStateException` on a non-blocking thread. It defeats WebFlux entirely. - Side effects placed *outside* the returned chain (imperative statements in the method body) run at assembly time, not per-subscription — a common source of bugs. Put work *inside* operators. ## 2. The threading danger Reactor Netty serves requests on a **small fixed pool of event-loop threads** (roughly one per CPU core). One blocked thread can't serve its share of connections, so blocking is catastrophic under load. Rules: - No blocking I/O (JDBC, blocking HTTP clients, file I/O) directly in the chain. - If you must call a blocking API, isolate it: `Mono.fromCallable(() -> blockingCall()).subscribeOn(Schedulers.boundedElastic())`. - `publishOn`/`subscribeOn` control which scheduler runs downstream/upstream work. BlockHound is a tool that can detect accidental blocking in tests. ## 3. Returning proper HTTP status Annotated handlers give several levers: - **`@ResponseStatus(HttpStatus.CREATED)`** on the method for a fixed status. - **`Mono<ResponseEntity<T>>`** to compute status/headers per result: `repo.findById(id).map(ResponseEntity::ok).defaultIfEmpty(ResponseEntity.notFound().build())`. - **`ResponseStatusException`** thrown or emitted via `Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "..."))` for error statuses without a custom exception class. - **`.switchIfEmpty(...)`** to map an empty `Mono` (which otherwise yields 200 + empty body) to a 404. Prefer `Mono<ResponseEntity<T>>` over `ResponseEntity<Mono<T>>` when the status itself depends on the async result — with the latter the status is fixed before the body resolves. ## 4. Reactive error handling In reactive code an error is an **onError signal** flowing down the pipeline, not a thrown exception you catch with try/catch (the failure often happens on another thread, later). Operators: - `onErrorResume(ex -> Mono.just(fallback))` — substitute a fallback publisher. - `onErrorReturn(fallbackValue)` — substitute a constant. - `onErrorMap(ex -> new ApiException(...))` — translate the exception type. - `doOnError(...)` — side-effect (logging) without handling. For cross-cutting handling, `@ExceptionHandler` methods (in the controller or a `@ControllerAdvice`) work just like MVC but **return `Mono`/`Flux`**. A `WebExceptionHandler`/`AbstractErrorWebExceptionHandler` handles errors at the framework edge (Spring Boot's default error handling is built on this). ## 5. Context propagation gotcha Because work runs across threads and subscription is deferred, **`ThreadLocal` does not survive**. Anything ThreadLocal-based — SecurityContext, MDC logging, transaction context — must be carried in the **Reactor `Context`**. Spring Security's reactive support (`ReactiveSecurityContextHolder`) and Micrometer context-propagation exist precisely for this. Assuming ThreadLocal works is a classic senior-level bug in reactive code. ## 6. When to use / avoid The annotated reactive model is excellent when the whole stack is non-blocking (R2DBC, WebClient) and you need high concurrency, streaming, or backpressure. If your dependencies are blocking, the constant `boundedElastic` offloading erodes the benefit and MVC (or virtual-thread MVC) is usually the simpler, equally performant choice.

  • Why doesn't ThreadLocal-based SecurityContext or MDC work in a WebFlux controller, and what replaces it?
    Reactive execution hops threads and defers subscription, so a value set in a ThreadLocal on one thread isn't visible where the work actually runs. Context must travel in the Reactor Context instead — Spring Security uses ReactiveSecurityContextHolder, and Micrometer context-propagation bridges MDC. Assuming ThreadLocal survives is a classic bug.
  • When would you pick Mono<ResponseEntity<T>> over ResponseEntity<Mono<T>>?
    When the status/headers depend on the async result — e.g. 200 vs 404 based on whether the entity exists. With ResponseEntity<Mono<T>> the status is fixed before the body resolves, so you can't decide 404-vs-200 from the emitted value; with Mono<ResponseEntity<T>> you map the resolved value to the right status.
  • How do you safely call a blocking library from a WebFlux handler?
    Wrap it in Mono.fromCallable(...) and subscribeOn(Schedulers.boundedElastic()) so the blocking runs on a dedicated elastic pool instead of the event loop. Better still, replace it with a reactive equivalent (R2DBC, WebClient) so nothing blocks at all.

saying these in an interview costs you the question

  • Calling .block() or .subscribe() inside the handler
  • Using try/catch to handle errors instead of onError operators
  • Assuming ThreadLocal (SecurityContext/MDC) propagates across the reactive chain
  • Doing blocking JDBC directly on the event-loop thread
  • Believing ResponseEntity<Mono<T>> lets status depend on the resolved body

context