skip to content

You must call a legacy blocking JDBC repository from a WebFlux handler. How do you integrate it without starving the event loop?

level: seniorimportance: should knowfreq 58%

answer

  1. fromCallable = lazy; just = eager (wrong)
  2. subscribeOn(boundedElastic) moves the source off the loop
  3. boundedElastic: capped 10×cores, reuses, queues
  4. boundedElastic threads are blockable (not NonBlocking)
  5. Size to the JDBC/Hikari pool, not the thread cap

basics

~10 s

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

Wrap 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
java
@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

for a junior

Knows the phrase subscribeOn(Schedulers.boundedElastic()) offloads blocking work.

for a middle

Explains fromCallable vs just (lazy vs eager) and why boundedElastic (capped, blockable).

for a senior

Sizes the scheduler against the connection pool, handles transaction/thread-affinity, distinguishes subscribeOn/publishOn.

for a principal

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

context