Spring Webflux
JDBC
Blocking Call
Reactive Programming
Spring Framework

Execute blocking JDBC call in Spring Webflux

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Spring WebFlux is built around non-blocking request handling, while JDBC is fundamentally blocking. That mismatch is the core issue: if you call JDBC directly on a Netty event-loop thread, one slow query can stall request processing for unrelated clients.

The Rule: Never Block the Event Loop

In WebFlux, request handling often starts on a small set of event-loop threads. Those threads are designed to hand work off quickly, not to sit idle waiting for a database round trip.

This is the anti-pattern:

java
1@GetMapping("/users/{id}")
2public Mono<User> findUser(@PathVariable long id) {
3    User user = jdbcTemplate.queryForObject(
4        "select id, name from users where id = ?",
5        (rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name")),
6        id
7    );
8    return Mono.just(user);
9}

The method returns Mono<User>, but the JDBC call already blocked before the Mono was created. That means the code looks reactive while behaving synchronously.

Offload Blocking JDBC Work Explicitly

If you must keep JDBC, wrap the blocking work in Mono.fromCallable or Flux.defer and schedule it on Schedulers.boundedElastic().

java
1import org.springframework.jdbc.core.JdbcTemplate;
2import org.springframework.stereotype.Service;
3import reactor.core.publisher.Mono;
4import reactor.core.scheduler.Schedulers;
5
6@Service
7public class UserService {
8    private final JdbcTemplate jdbcTemplate;
9
10    public UserService(JdbcTemplate jdbcTemplate) {
11        this.jdbcTemplate = jdbcTemplate;
12    }
13
14    public Mono<User> findUser(long id) {
15        return Mono.fromCallable(() ->
16                jdbcTemplate.queryForObject(
17                    "select id, name from users where id = ?",
18                    (rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name")),
19                    id
20                )
21            )
22            .subscribeOn(Schedulers.boundedElastic());
23    }
24}

boundedElastic exists specifically for blocking work such as file I/O, JDBC, or legacy SDK calls. It prevents that work from consuming the event-loop threads.

Keep the Blocking Boundary in the Service Layer

A clean architecture makes the blocking boundary obvious. Controllers stay reactive, while the service encapsulates the blocking database call.

java
1import org.springframework.web.bind.annotation.GetMapping;
2import org.springframework.web.bind.annotation.PathVariable;
3import org.springframework.web.bind.annotation.RestController;
4import reactor.core.publisher.Mono;
5
6@RestController
7public class UserController {
8    private final UserService userService;
9
10    public UserController(UserService userService) {
11        this.userService = userService;
12    }
13
14    @GetMapping("/users/{id}")
15    public Mono<User> getUser(@PathVariable long id) {
16        return userService.findUser(id);
17    }
18}

This is easier to reason about than letting controllers sprinkle scheduler logic around each query.

Prefer R2DBC When the Whole Stack Should Be Reactive

Wrapping JDBC in boundedElastic is a compatibility strategy, not a fully reactive data layer. If the application is genuinely meant to be reactive end to end, R2DBC is the better fit because it uses non-blocking database drivers.

Use wrapped JDBC when these conditions are true:

  • you are migrating incrementally from Spring MVC to WebFlux
  • a legacy library requires JDBC
  • rewriting the persistence layer is not yet practical

Use R2DBC when the goal is a consistently reactive stack with fewer hidden blocking points.

Be Honest About Throughput Limits

Moving JDBC to boundedElastic avoids blocking the event loop, but it does not make the database call non-blocking. Queries still occupy threads while waiting on the database.

That means throughput is now limited by:

  • the bounded elastic scheduler capacity
  • the JDBC connection pool
  • database latency

If load increases, the system can still saturate. The architecture is safer than blocking the event loop, but it is not magical.

Handling Transactions and Errors

When you keep JDBC, use the normal Spring transaction model for that blocking code path. Do not expect reactive transaction semantics to appear automatically just because the return type is Mono.

Error handling also belongs in the reactive chain:

java
1public Mono<User> findUser(long id) {
2    return Mono.fromCallable(() ->
3            jdbcTemplate.queryForObject(
4                "select id, name from users where id = ?",
5                (rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name")),
6                id
7            )
8        )
9        .subscribeOn(Schedulers.boundedElastic())
10        .onErrorMap(ex -> new IllegalStateException("Database read failed", ex));
11}

That keeps failure handling explicit instead of letting checked and unchecked exceptions leak unpredictably across layers.

Avoid Mixing Reactive Signatures With Hidden Blocking

One of the worst outcomes is a codebase that returns Mono and Flux everywhere while doing blocking work directly under the surface. That gives you the complexity of reactive code without the operational benefit.

If a path is blocking, isolate it and label it honestly. That is much easier to maintain.

Common Pitfalls

The most common mistake is wrapping a blocking result with Mono.just(...) after the blocking call already finished. That changes the type, not the behavior.

Another mistake is calling .block() inside WebFlux request handling. That reintroduces synchronous waiting and can cause the same thread starvation problems you were trying to avoid.

Teams also assume boundedElastic makes JDBC reactive. It does not. It only relocates the blocking work to a safer scheduler.

Finally, avoid scattering subscribeOn at random points in the chain. Keep the blocking boundary close to the blocking call so the code remains obvious.

Summary

  • JDBC is blocking, so it must not run on WebFlux event-loop threads.
  • Wrap JDBC calls in Mono.fromCallable and use Schedulers.boundedElastic().
  • Keep the blocking boundary in the service layer.
  • Use R2DBC if the application truly needs end-to-end reactive database access.
  • Reactive return types do not make blocking code non-blocking by themselves.

Course illustration
Course illustration

All Rights Reserved.