You must call a legacy blocking JDBC repository from a WebFlux handler. How do you integrate it without starving the event loop?
answer
- fromCallable = lazy; just = eager (wrong)
- subscribeOn(boundedElastic) moves the source off the loop
- boundedElastic: capped 10×cores, reuses, queues
- boundedElastic threads are blockable (not NonBlocking)
- Size to the JDBC/Hikari pool, not the thread cap
basics
~10 sWrap the blocking call in Mono.fromCallable(...) and move it off the event loop with .subscribeOn(Schedulers.boundedElastic()). That runs it on an elastic worker pool built for blocking I/O, keeping the event-loop threads free.
solid answer
~40 sWrap the blocking work in `Mono.fromCallable(() -> repo.find(id))` (or `Flux.fromIterable(...)` for collections) and add `.subscribeOn(Schedulers.boundedElastic())`. `fromCallable` defers execution until subscription; `subscribeOn` dictates which scheduler runs the source, so the JDBC call happens on a `boundedElastic` worker instead of `reactor-http-nio`. `boundedElastic` is Reactor's built-in scheduler for blocking/legacy I/O: it grows on demand, caps at ~10× CPU cores by default, reuses idle threads, and queues excess tasks — protecting against unbounded thread creation. Key points: `boundedElastic` threads are *not* marked non-blocking, so blocking there is allowed and won't trip Reactor's guard or BlockHound. Size it against your JDBC connection pool so you don't oversubscribe the database. For heavy blocking workloads, define a dedicated `Scheduler` rather than sharing the global one, and remember to keep the whole chain lazy (no eager `.block()`).
code
java · 17 lines@Service
public class UserFacade {
private final JdbcUserRepository jdbcRepo; // blocking JDBC
private final Scheduler jdbcScheduler; // dedicated, sized to the pool
public UserFacade(JdbcUserRepository jdbcRepo) {
this.jdbcRepo = jdbcRepo;
// cap threads near the Hikari connection count to avoid oversubscribing the DB
this.jdbcScheduler = Schedulers.newBoundedElastic(10, 1000, "jdbc");
}
public Mono<User> findById(Long id) {
return Mono.fromCallable(() -> jdbcRepo.findById(id)) // lazy: runs on subscribe
.subscribeOn(jdbcScheduler); // off the event loop
}
}go deeper
Knows the phrase subscribeOn(Schedulers.boundedElastic()) offloads blocking work.
Explains fromCallable vs just (lazy vs eager) and why boundedElastic (capped, blockable).
Sizes the scheduler against the connection pool, handles transaction/thread-affinity, distinguishes subscribeOn/publishOn.
Weighs offloading vs going fully reactive (R2DBC) vs choosing MVC; isolates schedulers per subsystem to prevent cross-starvation.
**The situation.** You can't rewrite the DAO to R2DBC yet, but the endpoint is on WebFlux. You need the blocking JDBC call to run *somewhere other than the event loop*. **The pattern.** ```java return Mono.fromCallable(() -> jdbcRepo.findById(id)) // defer the blocking call .subscribeOn(Schedulers.boundedElastic()); // run it off the event loop ``` **Piece by piece.** - `Mono.fromCallable(supplier)` wraps a synchronous, possibly-throwing computation and runs it **lazily** — only when subscribed. (`Mono.just(repo.find(id))` is wrong: `find` executes *eagerly* when you build the Mono, right there on the event loop, before any scheduler can help.) - `subscribeOn(scheduler)` changes the thread on which the **source** is subscribed and thus where the blocking work runs. Placement in the chain doesn't matter much for a single source — `subscribeOn` affects the whole upstream regardless of position. (Contrast `publishOn`, which switches threads only for operators *downstream* of it — useful if you want to hop back for CPU work after the I/O.) **Why `Schedulers.boundedElastic()`.** It's Reactor's purpose-built scheduler for **blocking and legacy I/O**: - Creates worker threads on demand and **caps** the count (default `10 × number of CPU cores`), unlike the old unbounded `elastic()` (deprecated) which could exhaust the machine. - **Reuses** idle workers and evicts them after 60s idle. - **Queues** submitted tasks (bounded queue, default 100k per attempt) when all workers are busy, applying backpressure rather than spawning infinite threads. - Its threads are **not** `NonBlocking`, so calling blocking code on them is legal and won't be flagged by Reactor's `block()` guard or by BlockHound. **Sizing and the database.** `boundedElastic` can hold many threads, but your JDBC connection pool (HikariCP) is the real bottleneck — e.g. 10 connections. If 200 boundedElastic threads all try to grab a connection, 190 block waiting on the pool. Match concurrency to the pool: either size a **dedicated scheduler** (`Schedulers.newBoundedElastic(poolSize, queueCap, "jdbc")`) to the connection count, or gate with a semaphore. Sharing the global `boundedElastic` across all blocking work risks one slow subsystem starving another. **Other gotchas.** - Don't mix in a stray `.block()` — offloading + block still blocks *some* thread and often defeats the purpose. - `@Transactional` on JDBC needs the whole unit of work on one boundedElastic thread; don't split a transaction across `flatMap` boundaries that hop schedulers. - This is a *bridge*, not a goal. End-to-end reactive (R2DBC + `ReactiveCrudRepository`) avoids the extra thread pool entirely. Offloading trades away some of WebFlux's efficiency; if most of your stack is blocking, plain Spring MVC is usually the better architecture. - Spring provides no auto-magic for this — there's no `@Async`-style reactive offload for blocking repos; you write the `subscribeOn` explicitly.
- Why Mono.fromCallable and not Mono.just for wrapping the blocking call?Mono.just(repo.find(id)) evaluates repo.find(id) eagerly while building the Mono — on the event loop — so subscribeOn can't help. fromCallable defers the call to subscription time, when it runs on the chosen scheduler.
- You offloaded to boundedElastic but the DB still bottlenecks under load. Why?boundedElastic can hold far more threads than your JDBC connection pool has connections. Excess threads just block waiting for a connection. Size a dedicated scheduler to the pool (e.g. Hikari maximumPoolSize) or gate concurrency.
- What's the difference between subscribeOn and publishOn here?subscribeOn sets the thread for the source subscription (the whole upstream) regardless of position. publishOn switches threads only for operators downstream of it — use it to hop off boundedElastic back to a compute scheduler after the blocking read.
saying these in an interview costs you the question
- Using Mono.just(blockingCall()) and thinking subscribeOn saves it
- Sizing only the thread pool and ignoring the JDBC connection pool
- Using Schedulers.parallel() (marked NonBlocking) for blocking work
- Treating offloading as the ideal rather than a bridge toward R2DBC