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?
answer
- Framework subscribes; never .subscribe/.block
- Event loop is tiny — offload to boundedElastic
- Mono<ResponseEntity> for dynamic status
- Errors are onError signals: onErrorResume/Map/Return
- ThreadLocal dead — use Reactor Context
basics
~10 sThe 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 sSpring'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@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
Know the framework subscribes and you must not block the event loop.
Use ResponseStatusException/ResponseEntity for status and onErrorResume for fallbacks.
Offload blocking to boundedElastic, centralize with @ExceptionHandler returning Mono, map empty to 404.
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