You must call a legacy blocking library (JDBC / RestTemplate) inside a WebFlux handler. How do you keep from blocking the event loop, and what does the correct code look like?
answer
- fromCallable + subscribeOn(boundedElastic)
- never .block() on the event loop
- just() = eager = wrong; defer/fromCallable = lazy
- isolate heavy blocking → newBoundedElastic
- BlockHound catches accidental blocking
basics
~10 sWrap the blocking call in Mono.fromCallable(...) (or Flux) and add subscribeOn(Schedulers.boundedElastic()), so the blocking work runs on the I/O pool instead of the Netty event-loop thread.
solid answer
~40 sWebFlux serves requests on a handful of non-blocking Netty event-loop threads; a synchronous JDBC/`RestTemplate` call on one of them stalls every request it multiplexes. The fix is to isolate the blocking call in a deferred producer and move it onto `Schedulers.boundedElastic()`: `Mono.fromCallable(() -> blockingCall()).subscribeOn(Schedulers.boundedElastic())`. Use `fromCallable` (not `just`) so the call executes lazily at subscription on the boundedElastic thread, not eagerly on the assembling thread. For multiple values use `Flux.fromIterable(...)`/`Flux.defer(...)` similarly. boundedElastic caps threads (default 10×cores) and queues overflow, so a burst of blocking calls parks threads safely without exploding. For heavy blocking subsystems, isolate a dedicated `Schedulers.newBoundedElastic(...)` so one slow dependency can't starve the shared pool. Add BlockHound in tests to catch accidental blocking on event-loop/parallel threads.
code
java · 24 linesimport org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@RestController
class ReportController {
private final LegacyJdbcDao dao; // blocking
private final RestTemplate legacyRest; // blocking
ReportController(LegacyJdbcDao dao, RestTemplate legacyRest) {
this.dao = dao; this.legacyRest = legacyRest;
}
@GetMapping("/reports/{id}")
Mono<Report> report(@PathVariable long id) {
return Mono.fromCallable(() -> dao.loadReport(id)) // deferred blocking JDBC
.zipWith(Mono.fromCallable(() -> // deferred blocking HTTP
legacyRest.getForObject("/enrich/" + id, Meta.class)))
.map(t -> t.getT1().withMeta(t.getT2()))
// ONE subscribeOn moves the whole blocking source onto the I/O pool:
.subscribeOn(Schedulers.boundedElastic());
// Netty event-loop thread is never blocked.
}
}go deeper
Know the wrapper: fromCallable + subscribeOn(boundedElastic); don't block the event loop.
Explain why just() is wrong and why boundedElastic (not parallel) is the target.
Discuss dedicated pools, BlockHound, timeouts, and that offloading isn't as scalable as R2DBC/WebClient.
Reason about capacity planning per-thread-per-call, bulkheading slow dependencies, and migration strategy off JDBC/RestTemplate.
## Why blocking the event loop is fatal Spring WebFlux on Reactor Netty runs on a **small, fixed set of event-loop threads** (roughly one per CPU core). Each such thread multiplexes *many* concurrent connections. If your handler makes a **synchronous blocking call** — JDBC via `JdbcTemplate`, `RestTemplate`, `Thread.sleep`, blocking file I/O — that thread is parked for the whole call duration and **cannot service any other request** it was handling. Under load, all event-loop threads get stuck and throughput collapses (functionally a self-inflicted DoS). This is the single most common WebFlux mistake. ## The offloading pattern Move the blocking work onto `Schedulers.boundedElastic()`, which exists exactly for parking blocking I/O on a capped, reusable pool: ```java Mono<Account> account = Mono.fromCallable(() -> jdbcAccountDao.load(id)) .subscribeOn(Schedulers.boundedElastic()); ``` Key points: - **`fromCallable` / `defer`, not `just`.** `Mono.just(blockingCall())` executes the blocking call **eagerly** on the thread assembling the pipeline (often the event loop) — the wrapper is useless. `fromCallable` defers execution to subscription time, and `subscribeOn` ensures that subscription happens on boundedElastic. - **`subscribeOn`, not `publishOn`, for the source.** You want the *source itself* to run on boundedElastic; subscribeOn governs where the source is subscribed/emits. - For collections/streams: `Flux.defer(() -> Flux.fromIterable(blockingQuery()))` (or `Flux.fromStream`) `.subscribeOn(Schedulers.boundedElastic())`. ## Sizing & isolation boundedElastic is bounded (default thread cap **10×cores**, task queue cap 100 000, 60s idle eviction). It's robust but **not infinite**: if a slow dependency floods it, queued tasks back up and latency climbs. For a heavy blocking subsystem, create a **dedicated** pool so it can't starve everything else: ```java private static final Scheduler REPORT_POOL = Schedulers.newBoundedElastic(20, 1000, "report-io"); ``` ## Verifying you didn't block - **BlockHound** (`reactor-tools`/`blockhound`) instruments the JVM to throw if a blocking call runs on a non-blocking Scheduler (event loop, parallel). Wire it into tests to catch regressions. - Prefer truly non-blocking clients where possible: `WebClient` instead of `RestTemplate`, R2DBC instead of JDBC. Offloading to boundedElastic is a **bridge** for code you can't make reactive, not the ideal — you still consume a thread per in-flight blocking call, losing some of reactive's scalability. ## Gotchas - Don't call `.block()` inside a reactive chain on the event loop — it throws in Reactor Netty (`block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-…`). - Wrapping in boundedElastic makes it *safe*, not *free*: N concurrent blocking calls tie up N threads; capacity-plan accordingly. - Applying `subscribeOn` once near the blocking source is enough; sprinkling it everywhere is noise (closest-to-source wins).
- Why Mono.fromCallable instead of Mono.just for wrapping the blocking call?Mono.just evaluates its argument eagerly, so the blocking call runs on the assembling (event-loop) thread before subscribeOn can move it. fromCallable defers execution to subscription time, which subscribeOn dispatches onto boundedElastic.
- Offloading to boundedElastic works — is it as scalable as a native reactive driver?No. You still consume one thread per in-flight blocking call, so you lose the 'few threads, many connections' advantage. It's a bridge; prefer WebClient/R2DBC for true non-blocking scalability.
- How would you protect the rest of the app from one flaky, slow blocking dependency?Give it a dedicated Schedulers.newBoundedElastic(...) with its own thread/queue caps so its saturation can't starve the shared pool, plus timeouts and possibly a circuit breaker.
saying these in an interview costs you the question
- Using Mono.just(blockingCall()) and thinking subscribeOn saves it
- Calling .block() inside a WebFlux handler
- Offloading to Schedulers.parallel() instead of boundedElastic
- Believing boundedElastic makes blocking 'free' with unlimited capacity