Back to Blog
spring-webfluxr2dbcreactivespring-bootjavapostgresql

Spring WebFlux + R2DBC: Fully Reactive Database Access (2026)

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.

J

JOptimize Team

May 30, 2026· 9 min read

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.


When to Choose R2DBC Over JPA

Before diving in, be clear about the trade-offs:

Choose R2DBC when:

  • You're building a genuinely reactive application with WebFlux throughout
  • You have high concurrency with many simultaneous database operations
  • Your app needs to handle thousands of concurrent connections with minimal threads

Choose JPA/Hibernate when:

  • You need the rich ORM features — lazy loading, first-level cache, dirty checking, associations
  • Your team is more familiar with JPA and you're on Java 21 with virtual threads (which make JPA non-blocking effectively)
  • You need complex mapping, inheritance, or lifecycle callbacks

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.


Setup

<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 Entity and Repository

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 Layer with Reactive Types

@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); } }

Reactive Controller

@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); } }

R2DBC Limitations vs JPA

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.


Common Mistakes to Avoid

  • Blocking inside a reactive chain — never call blocking code (JDBC, Thread.sleep, synchronous HTTP calls) inside a Mono/Flux chain; use Schedulers.boundedElastic() to offload blocking operations
  • Not subscribing — reactive pipelines don't execute until someone subscribes; in Spring WebFlux, the framework subscribes when it returns a Mono/Flux from a controller; in services, you must ensure the pipeline is subscribed
  • Mixing JDBC and R2DBC — don't use Flyway with R2DBC for migrations; run Flyway with JDBC before R2DBC starts, or use flyway.enabled=true with a separate JDBC URL
  • Ignoring backpressure — Flux streams can overwhelm consumers; use operators like limitRate(), onBackpressureBuffer(), or onBackpressureDrop() for fast producers

Summary

Spring 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.


Analyze Your Reactive App for Data Access Issues

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.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.