WebFlux with JDBC blocks the reactive pipeline. R2DBC provides truly non-blocking database access for reactive Spring Boot applications. Here's how to use it correctly.
JOptimize Team
If you're using Spring WebFlux for a reactive application and JDBC for database access, you have a non-blocking HTTP layer wrapping a blocking database layer. Every database call blocks a thread, and that thread can't serve other requests while it waits. The reactive pipeline leaks.
R2DBC (Reactive Relational Database Connectivity) solves this. It provides a non-blocking API for relational databases — PostgreSQL, MySQL, H2, Oracle, MSSQL — that integrates with Spring WebFlux without blocking any threads.
Before diving in, be clear about the trade-offs:
Choose R2DBC when:
Choose JPA/Hibernate when:
With Java 21 virtual threads, JPA + WebMVC can handle very high concurrency without blocking OS threads. R2DBC is still the right choice for pure reactive systems, but the gap has narrowed.
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-r2dbc</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>r2dbc-postgresql</artifactId> </dependency>
spring: r2dbc: url: r2dbc:postgresql://localhost:5432/mydb username: ${DB_USER} password: ${DB_PASSWORD} pool: initial-size: 5 max-size: 20 max-idle-time: 30m flyway: url: jdbc:postgresql://localhost:5432/mydb # Flyway still uses JDBC for migrations user: ${DB_USER} password: ${DB_PASSWORD}
R2DBC entities look similar to JPA entities but without the JPA annotations:
// R2DBC entity — no @Entity, no @Id from javax.persistence import org.springframework.data.annotation.Id; import org.springframework.data.relational.core.mapping.Table; @Table("orders") public class Order { @Id // org.springframework.data.annotation.Id private Long id; private Long customerId; @Column("status") private OrderStatus status; private BigDecimal total; @CreatedDate private Instant createdAt; // Standard getters/setters OR use Lombok @Data // Records don't work well here (R2DBC needs mutable state) }
// Reactive repository — returns Flux and Mono instead of List and Optional @Repository public interface OrderRepository extends ReactiveCrudRepository<Order, Long> { Flux<Order> findByCustomerId(Long customerId); // Returns all matching orders Mono<Order> findFirstByCustomerIdOrderByCreatedAtDesc(Long customerId); Flux<Order> findByStatusAndCustomerIdOrderByCreatedAtDesc( OrderStatus status, Long customerId); // Custom query with @Query @Query("SELECT * FROM orders WHERE total > :minTotal AND status = :status") Flux<Order> findByMinTotalAndStatus(BigDecimal minTotal, String status); Mono<Long> countByStatus(OrderStatus status); }
@Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepo; private final CustomerService customerService; public Mono<OrderDto> getOrder(Long orderId) { return orderRepo.findById(orderId) .switchIfEmpty(Mono.error(new OrderNotFoundException(orderId))) .map(OrderMapper::toDto); } // Combine multiple reactive sources public Mono<OrderDetailDto> getOrderDetail(Long orderId) { return orderRepo.findById(orderId) .switchIfEmpty(Mono.error(new OrderNotFoundException(orderId))) .flatMap(order -> customerService.getCustomer(order.getCustomerId()) // Another reactive call .map(customer -> new OrderDetailDto(order, customer)) ); } // Process a list of orders in parallel public Flux<OrderDto> getOrdersByCustomer(Long customerId) { return orderRepo.findByCustomerId(customerId) .map(OrderMapper::toDto) .onErrorResume(ex -> { log.error("Error fetching orders for customer {}: {}", customerId, ex.getMessage()); return Flux.empty(); // Return empty stream on error }); } // Create with reactive transaction @Transactional public Mono<Order> createOrder(CreateOrderRequest req) { Order order = Order.create(req); return orderRepo.save(order); } }
@RestController @RequestMapping("/api/v1/orders") @RequiredArgsConstructor public class OrderController { private final OrderService orderService; // Mono<T> for single item responses @GetMapping("/{id}") public Mono<ResponseEntity<OrderDto>> getOrder(@PathVariable Long id) { return orderService.getOrder(id) .map(ResponseEntity::ok) .onErrorResume(OrderNotFoundException.class, ex -> Mono.just(ResponseEntity.notFound().build())); } // Flux<T> for collection responses — streaming JSON @GetMapping(produces = MediaType.APPLICATION_NDJSON_VALUE) // Newline-delimited JSON public Flux<OrderDto> streamOrders(@RequestParam Long customerId) { return orderService.getOrdersByCustomer(customerId); // Client receives each order as it's fetched — streaming response } // SSE for real-time updates @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<OrderDto> streamAllOrders() { return orderService.getAllOrders() .delayElements(Duration.ofMillis(100)); // Throttle if needed } @PostMapping @ResponseStatus(HttpStatus.CREATED) public Mono<Order> createOrder(@RequestBody @Valid CreateOrderRequest req) { return orderService.createOrder(req); } }
Understanding what R2DBC doesn't have helps you plan correctly:
No lazy loading — R2DBC doesn't support associations with lazy loading. To load an order with its items, you need to write explicit join queries:
// JPA: order.getItems() loads lazily // R2DBC: you must fetch explicitly public Mono<OrderWithItems> getOrderWithItems(Long orderId) { return orderRepo.findById(orderId) .flatMap(order -> orderItemRepo.findByOrderId(orderId) .collectList() .map(items -> new OrderWithItems(order, items)) ); }
No first-level cache — every query hits the database; there's no Hibernate session cache. For read-heavy workloads, layer a reactive cache (Caffeine + reactive wrapper) on top.
Limited ORM features — no inheritance mapping, no complex lifecycle callbacks, no automatic dirty checking.
Thread.sleep, synchronous HTTP calls) inside a Mono/Flux chain; use Schedulers.boundedElastic() to offload blocking operationsMono/Flux from a controller; in services, you must ensure the pipeline is subscribedflyway.enabled=true with a separate JDBC URLlimitRate(), onBackpressureBuffer(), or onBackpressureDrop() for fast producersSpring WebFlux + R2DBC delivers genuinely non-blocking database access for reactive Spring Boot applications. The API mirrors Spring Data JPA — repositories return Flux<T> instead of List<T> and Mono<T> instead of Optional<T>. The main trade-off is losing JPA's rich ORM features (lazy loading, dirty checking, associations). For new reactive applications, R2DBC is the right choice. For existing JPA applications migrating to higher concurrency on Java 21, virtual threads often provide enough benefit without the R2DBC migration cost.
N+1 patterns exist in reactive code too — multiple sequential flatMap calls that could be a single join query. JOptimize helps you find them.
Reactive all the way through.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.