Microservices

R2DBC & Reactive SQL with Spring WebFlux: Complete Production Guide (2026)

Md Sanwar Hossain April 11, 2026 22 min read
R2DBC Reactive SQL Spring WebFlux Production 2026
TL;DR
  • R2DBC replaces JDBC with non-blocking reactive SQL — pair it with Spring WebFlux for a fully reactive stack
  • Spring Data R2DBC repositories return Mono<T> / Flux<T> — drop-in reactive replacement for JPA patterns
  • Use DatabaseClient for custom SQL; TransactionalOperator or @Transactional for reactive transactions
  • Configure r2dbc-pool with initialSize, maxSize, maxIdleTime — wrong pool config kills throughput
  • Test with Testcontainers + R2DBC H2 for fast unit tests; monitor via Spring Actuator pool metrics
Table of Contents
  1. Why Reactive SQL? Blocking vs Non-Blocking
  2. R2DBC vs JDBC: Key Differences
  3. Setting Up R2DBC with Spring Boot
  4. Spring Data R2DBC Repositories
  5. Custom SQL with DatabaseClient
  6. Reactive Transactions
  7. Connection Pool Configuration
  8. Handling Backpressure in Database Queries
  9. R2DBC + Spring WebFlux: Full Reactive Stack
  10. Testing R2DBC with Testcontainers
  11. Monitoring R2DBC in Production
  12. Production Checklist

1. Why Reactive SQL? Blocking vs Non-Blocking

Traditional JDBC blocks the calling thread until the database returns a result. In a Spring MVC application with 200 Tomcat threads, a slow query blocks one thread for its full duration. Under 200 concurrent slow queries your server is saturated — new requests queue or time out.

Spring WebFlux uses an event loop (Netty) with typically 2× CPU-core threads. If those threads ever block on JDBC, the entire server stalls. R2DBC solves this: database calls become async I/O operations registered on the event loop, freeing threads to serve other requests while waiting for DB responses.

Throughput comparison (10K concurrent requests, 50ms DB latency):
Stack Threads Max RPS Memory
Spring MVC + JDBC200 (pool)~4,000~512 MB
Spring WebFlux + R2DBC16 (event loop)~40,000~128 MB

When NOT to use R2DBC: batch ETL jobs, complex ORM features (Hibernate lazy loading, entity graph), or when your team is unfamiliar with reactive programming — JDBC + virtual threads (Java 21) is a valid alternative for many use cases.

2. R2DBC vs JDBC: Key Differences

Feature JDBC R2DBC
Thread modelBlocking — 1 thread per queryNon-blocking — event loop
Return typesResultSet, Object, ListMono<T>, Flux<T>
ORM supportHibernate, JPA, MyBatisSpring Data R2DBC only
Transaction API@Transactional (synchronous)@Transactional + ReactiveTransactionManager
Connection poolHikariCPr2dbc-pool
Streaming resultsManual ResultSet iterationNative Flux backpressure

3. Setting Up R2DBC with Spring Boot

Add the Spring Data R2DBC starter plus the driver for your database:

<!-- Maven -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<!-- PostgreSQL reactive driver -->
<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>r2dbc-postgresql</artifactId>
</dependency>
<!-- R2DBC connection pool -->
<dependency>
  <groupId>io.r2dbc</groupId>
  <artifactId>r2dbc-pool</artifactId>
</dependency>

Configure in application.yml:

spring:
  r2dbc:
    url: r2dbc:postgresql://localhost:5432/mydb
    username: myuser
    password: secret
    pool:
      initial-size: 5
      max-size: 20
      max-idle-time: 30m
      validation-query: SELECT 1
Important: Do NOT include spring-boot-starter-data-jpa in the same project — it will auto-configure JDBC DataSource which blocks the event loop. R2DBC and JPA are mutually exclusive.

4. Spring Data R2DBC Repositories

Define your entity with @Table and @Id, then extend ReactiveCrudRepository:

@Table("orders")
public class Order {
    @Id
    private Long id;
    private String customerId;
    private BigDecimal total;
    private OrderStatus status;
    private LocalDateTime createdAt;
    // getters/setters or use @Data (Lombok)
}

public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {

    Flux<Order> findByCustomerId(String customerId);

    Flux<Order> findByStatusAndCreatedAtAfter(OrderStatus status, LocalDateTime since);

    @Query("SELECT * FROM orders WHERE total > :minTotal ORDER BY created_at DESC LIMIT :limit")
    Flux<Order> findHighValueOrders(BigDecimal minTotal, int limit);

    Mono<Long> countByStatus(OrderStatus status);
}

Usage in a service:

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public Flux<Order> getActiveOrders(String customerId) {
        return orderRepository
            .findByCustomerId(customerId)
            .filter(o -> o.getStatus() == OrderStatus.ACTIVE)
            .doOnNext(o -> log.debug("Fetched order: {}", o.getId()));
    }

    public Mono<Order> createOrder(Order order) {
        order.setCreatedAt(LocalDateTime.now());
        order.setStatus(OrderStatus.PENDING);
        return orderRepository.save(order);
    }
}
Key difference from JPA: Spring Data R2DBC does NOT support lazy loading, entity graphs, or cascading. You must handle joins manually with DatabaseClient or use separate repository calls composed in the reactive chain.

5. Custom SQL with DatabaseClient

DatabaseClient gives you full SQL control with reactive execution — useful for JOINs, projections, or stored procedures:

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final DatabaseClient databaseClient;

    public Flux<OrderSummaryDTO> findOrdersWithCustomer(OrderStatus status) {
        return databaseClient.sql("""
                SELECT o.id, o.total, o.status,
                       c.name AS customer_name, c.email
                FROM orders o
                JOIN customers c ON c.id = o.customer_id
                WHERE o.status = :status
                ORDER BY o.created_at DESC
                """)
            .bind("status", status.name())
            .map((row, meta) -> new OrderSummaryDTO(
                row.get("id", Long.class),
                row.get("total", BigDecimal.class),
                row.get("customer_name", String.class),
                row.get("email", String.class)
            ))
            .all();
    }

    public Mono<Integer> bulkUpdateStatus(List<Long> ids, OrderStatus newStatus) {
        return databaseClient.sql("UPDATE orders SET status = :status WHERE id = ANY(:ids)")
            .bind("status", newStatus.name())
            .bind("ids", ids.toArray(new Long[0]))
            .fetch()
            .rowsUpdated()
            .map(Long::intValue);
    }
}

6. Reactive Transactions

Spring Boot auto-configures R2dbcTransactionManager. Use @Transactional on service methods — Spring wraps the reactive chain in a transaction context:

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;
    private final TransactionalOperator txOperator;

    // Declarative - simplest approach
    @Transactional
    public Mono<Payment> processPayment(Long orderId, BigDecimal amount) {
        return orderRepository.findById(orderId)
            .switchIfEmpty(Mono.error(new OrderNotFoundException(orderId)))
            .flatMap(order -> {
                order.setStatus(OrderStatus.PAID);
                return orderRepository.save(order);
            })
            .flatMap(order -> {
                Payment payment = new Payment(orderId, amount, PaymentStatus.COMPLETED);
                return paymentRepository.save(payment);
            });
        // If any Mono in the chain errors, the transaction rolls back
    }

    // Programmatic - more control
    public Mono<Payment> processPaymentProgrammatic(Long orderId, BigDecimal amount) {
        Mono<Payment> paymentFlow = orderRepository.findById(orderId)
            .flatMap(order -> paymentRepository.save(new Payment(orderId, amount)));
        return txOperator.transactional(paymentFlow);
    }
}
Pitfall: Never call .block() inside a @Transactional reactive method — it causes a deadlock because the blocking call waits for a thread that's in the event loop waiting for the transaction to complete.

7. Connection Pool Configuration (r2dbc-pool)

Unlike HikariCP which manages blocking threads, r2dbc-pool manages reactive connections. Wrong configuration is the #1 R2DBC production issue:

@Configuration
public class R2dbcConfig extends AbstractR2dbcConfiguration {

    @Override
    @Bean
    public ConnectionFactory connectionFactory() {
        ConnectionFactory postgresFactory = ConnectionFactories.get(
            ConnectionFactoryOptions.builder()
                .option(DRIVER, "postgresql")
                .option(HOST, "localhost")
                .option(PORT, 5432)
                .option(DATABASE, "mydb")
                .option(USER, "myuser")
                .option(PASSWORD, "secret")
                .option(SSL, false)
                .build()
        );

        return new ConnectionPool(
            ConnectionPoolConfiguration.builder(postgresFactory)
                .name("mydb-pool")
                .initialSize(5)
                .maxSize(20)
                .maxIdleTime(Duration.ofMinutes(30))
                .maxLifeTime(Duration.ofHours(1))
                .maxAcquireTime(Duration.ofSeconds(5))  // acquire timeout
                .validationQuery("SELECT 1")
                .acquireRetry(3)
                .build()
        );
    }
}
Pool sizing formula:

For reactive applications: maxSize = (numCores × 2) + effective_spindle_count. Since the event loop never blocks, you need far fewer connections than JDBC — start with 20 and tune based on r2dbc.pool.acquired metrics.

8. Handling Backpressure in Database Queries

R2DBC's Flux naturally supports backpressure — the subscriber controls how fast rows are fetched. This prevents OOM when querying large tables:

// Stream a large result set without loading everything in memory
public Flux<Order> streamAllOrders() {
    return orderRepository.findAll()
        .buffer(500)                    // process 500 rows at a time
        .flatMapSequential(batch -> processBatch(batch), 4)  // 4 concurrent batches
        .doOnNext(o -> log.info("Processed: {}", o.getId()));
}

// Paginate with reactive scroll (Spring Data 3.1+)
public Flux<Order> scrollOrders(ScrollPosition position) {
    return orderRepository.findAllBy(
        PageRequest.of(0, 100, Sort.by("id")),
        position
    );
}

// Export CSV via WebFlux streaming response
@GetMapping(value = "/orders/export", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> exportOrders() {
    return orderRepository.findAll()
        .map(o -> o.getId() + "," + o.getTotal() + "," + o.getStatus() + "\n");
}

9. R2DBC + Spring WebFlux: Full Reactive Stack Example

A complete order API with functional routing, reactive service, and R2DBC repository:

// Functional router (alternative to @RestController)
@Configuration
public class OrderRouter {

    @Bean
    public RouterFunction<ServerResponse> routes(OrderHandler handler) {
        return RouterFunctions.route()
            .GET("/api/orders/{id}", handler::getById)
            .GET("/api/orders", handler::listByCustomer)
            .POST("/api/orders", handler::create)
            .PUT("/api/orders/{id}/status", handler::updateStatus)
            .build();
    }
}

@Component
@RequiredArgsConstructor
public class OrderHandler {

    private final OrderService orderService;
    private final Validator validator;

    public Mono<ServerResponse> getById(ServerRequest request) {
        long id = Long.parseLong(request.pathVariable("id"));
        return orderService.findById(id)
            .flatMap(order -> ServerResponse.ok()
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(order))
            .switchIfEmpty(ServerResponse.notFound().build());
    }

    public Mono<ServerResponse> create(ServerRequest request) {
        return request.bodyToMono(CreateOrderRequest.class)
            .flatMap(req -> orderService.createOrder(req))
            .flatMap(saved -> ServerResponse
                .created(URI.create("/api/orders/" + saved.getId()))
                .bodyValue(saved));
    }
}

10. Testing R2DBC with Testcontainers

Use Testcontainers to spin up a real PostgreSQL container for integration tests:

@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class OrderRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @DynamicPropertySource
    static void configureR2dbc(DynamicPropertyRegistry registry) {
        registry.add("spring.r2dbc.url", () ->
            "r2dbc:postgresql://" + postgres.getHost() + ":" +
            postgres.getMappedPort(5432) + "/testdb");
        registry.add("spring.r2dbc.username", postgres::getUsername);
        registry.add("spring.r2dbc.password", postgres::getPassword);
    }

    @Autowired OrderRepository orderRepository;

    @Test
    void shouldSaveAndFindOrder() {
        Order order = new Order();
        order.setCustomerId("cust-1");
        order.setTotal(new BigDecimal("99.99"));
        order.setStatus(OrderStatus.PENDING);

        StepVerifier.create(
            orderRepository.save(order)
                .flatMap(saved -> orderRepository.findById(saved.getId()))
        )
        .assertNext(found -> {
            assertThat(found.getCustomerId()).isEqualTo("cust-1");
            assertThat(found.getStatus()).isEqualTo(OrderStatus.PENDING);
        })
        .verifyComplete();
    }
}

Use StepVerifier from reactor-test for all reactive assertions — never call .block() in tests when you can use StepVerifier.

11. Monitoring R2DBC in Production

Spring Boot Actuator exposes R2DBC pool metrics automatically when Micrometer is on the classpath:

# Key metrics to watch in Grafana
r2dbc.pool.acquired          # Currently checked-out connections
r2dbc.pool.pending           # Requests waiting for a connection (alert if > 0)
r2dbc.pool.idle              # Idle connections
r2dbc.pool.max.allocated     # Pool max size

# Prometheus alert: pool exhaustion
- alert: R2dbcPoolExhausted
  expr: r2dbc_pool_pending{application="order-service"} > 5
  for: 1m
  annotations:
    summary: "R2DBC pool pending > 5 for 1m — increase maxSize or optimize queries"
Slow query logging:

R2DBC doesn't have built-in slow query logging. Use a ProxyConnectionFactory wrapper (from r2dbc-proxy library) to log queries exceeding a threshold. Alternatively, enable PostgreSQL's log_min_duration_statement = 100 at the database level.

12. Production Checklist

✅ R2DBC Production Checklist
  • r2dbc-pool configured with maxSize, maxAcquireTime
  • validationQuery set (SELECT 1)
  • No .block() calls in reactive chain
  • @Transactional on service methods (not repos)
  • StepVerifier in all repository tests
  • Testcontainers for integration tests
  • r2dbc.pool.pending alert in Prometheus
  • Flyway/Liquibase schema migrations applied before app start
  • Connection timeout (maxAcquireTime) < HTTP timeout
  • Separate read replica connection for read-heavy queries
  • r2dbc-proxy for slow query tracing in staging
  • No JPA/Hibernate on same classpath
Q: Can I mix R2DBC and JDBC in the same application?

A: Technically yes, using separate DataSource and ConnectionFactory beans, but it creates complexity. A better pattern: use Spring MVC + JDBC for endpoints that need JPA features, and Spring WebFlux + R2DBC for high-throughput streaming endpoints — as separate microservices or separate handler chains.

Q: How do I handle DB migrations with R2DBC?

A: Use Flyway or Liquibase — they still run on a separate JDBC connection at startup (add the JDBC driver as test/provided scope). Spring Boot's spring.flyway.* configuration works alongside R2DBC; Flyway migrates the schema before the app's R2DBC connection pool connects.

Tags:
r2dbc spring boot reactive sql spring 2026 spring webflux r2dbc r2dbc postgresql spring data r2dbc reactive database spring boot

Leave a Comment

Related Posts

Microservices

Spring Security Filter Chain & JWT Refresh

Microservices

Redis Caching in Spring Boot Production

System Design

PostgreSQL Replication & High Availability

Microservices

Distributed Tracing with OpenTelemetry & Spring Boot

Back to Blog Last updated: April 11, 2026