skip to content

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?

level: seniorimportance: must knowfreq 80%

answer

  1. fromCallable + subscribeOn(boundedElastic)
  2. never .block() on the event loop
  3. just() = eager = wrong; defer/fromCallable = lazy
  4. isolate heavy blocking → newBoundedElastic
  5. BlockHound catches accidental blocking

basics

~10 s

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

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

for a junior

Know the wrapper: fromCallable + subscribeOn(boundedElastic); don't block the event loop.

for a middle

Explain why just() is wrong and why boundedElastic (not parallel) is the target.

for a senior

Discuss dedicated pools, BlockHound, timeouts, and that offloading isn't as scalable as R2DBC/WebClient.

for a principal

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

context