Skip to main content

Reactive Data Access with Spring Data R2DBC: CRUD, Transactions and When to Choose It

A hands-on Spring Data R2DBC guide on Spring Boot 4.1 and JDK 25: ReactiveCrudRepository, custom queries, DatabaseClient, reactive transactions that roll back, Testcontainers Postgres with StepVerifier, an honest look at having no relations, and when virtual threads with JPA are the better default. Every transcript comes from a real run.

You have a Spring service whose time is mostly spent waiting on a database. Somebody on the team says “make it reactive”, and now every repository method returns a Mono or a Flux instead of an object or a list. That is what Spring Data R2DBC gives you: the familiar repository programming model on top of a non-blocking database driver.

This post builds a small working example from nothing — CRUD, hand-written SQL, DatabaseClient, transactions that really roll back, and tests with a real PostgreSQL in Testcontainers — and then answers the question that matters more than the API: should you use it at all in the age of virtual threads? It is the data-layer companion to the earlier Virtual Threads vs WebFlux vs Platform Threads benchmark, which looked at the web layer.

Versions tested. Everything here was compiled and run on JDK 25 with Spring Boot 4.1.2 (latest GA on Maven Central on 2026-10-29; 4.2.0 is still a release candidate), which brings Spring Data R2DBC 4.1.2, Spring Framework 7.0.10, Reactor Core 3.8.8, r2dbc-pool 1.0.2 and r2dbc-postgresql 1.1.3.RELEASE (released 2026-09-21). The database is PostgreSQL 17 in Testcontainers 2.0.5. The R2DBC SPI itself is still at 1.0.0.RELEASE, published in April 2022. Companion code: spring-data-demo/r2dbc.

What R2DBC is, in one picture

JDBC, the Java database API since the 1990s, is blocking: you call statement.executeQuery() and your thread sits there until Postgres answers. R2DBC (Reactive Relational Database Connectivity) is a separate, newer specification for the same job where nothing blocks: you send the query, get back a Publisher, and the result is delivered later as an event. Spring Data R2DBC is the repository layer on top of it, in the same family as Spring Data JPA but much smaller.

Blocking JDBC (platform threads)thread 1parked, waiting on Postgresthread 2parked, waiting on Postgresthread 3parked, waiting on Postgresthread 4parked, waiting on PostgresFour requests, four threads, each holding a stack while it waits.R2DBC (reactive)event looprequest 1: query sent, not waitingrequest 2: query sent, not waitingrequest 3: query sent, not waitingrequest 4: query sent, not waitingOne thread sends all four queries and is free to do other work;each result arrives later as an event and resumes the pipeline.What to take from this: the waiting is what differs, not the database. Postgres sees the same SQL either way.Since Java 21, virtual threads make the left side cheap too, which is why the choice is no longer obvious.

The left half is what most Spring MVC apps do: one thread per in-flight request, parked while the database works. The right half is the reactive model: a small number of event-loop threads start the queries and move on. The database does the same work in both. What changes is what your application threads do while they wait.

One idea to carry through the whole post. A reactive repository method does not run a query. It returns a description of one. Nothing reaches the database until something subscribes. The CRUD section below shows this with a test, because it is the first thing that bites everyone.
Going deeper: what R2DBC is and is not

R2DBC is a specification (the SPI) plus per-database drivers. It is not part of the JDK and not an official Spring project; Spring Data R2DBC and spring-r2dbc (DatabaseClient) are the Spring-side integrations. The SPI has been at 1.0.0.RELEASE since April 2022, while the PostgreSQL driver is actively released (1.1.3.RELEASE on 2026-09-21 at the time of writing), so “stable” here means a small, settled API rather than abandoned. Both numbers come from the maven-metadata.xml and directory listings on Maven Central.

Unlike JDBC, there is no JPA-like standard on top. The mapping layer is Spring Data Relational, shared with Spring Data JDBC, and deliberately simple: one row, one object.

Set it up: dependencies, schema, configuration

You need the Spring Boot starter and a driver. The driver is on the runtime classpath only, because your code talks to Spring Data, never to the driver directly.

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-r2dbc</artifactId>
    </dependency>
    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>r2dbc-postgresql</artifactId>
      <scope>runtime</scope>
    </dependency>

Source: pom.xml

There is no Hibernate here, so no ddl-auto. You create tables yourself. Spring Boot will run a schema.sql from the classpath through the R2DBC connection at startup if you ask for it, so you do not need a JDBC driver just to create tables.

DROP TABLE IF EXISTS purchase_order;
DROP TABLE IF EXISTS account;
DROP TABLE IF EXISTS customer;

CREATE TABLE customer (
    id    BIGSERIAL PRIMARY KEY,
    name  TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    tier  TEXT NOT NULL DEFAULT 'STANDARD'
);

CREATE TABLE purchase_order (
    id          BIGSERIAL PRIMARY KEY,
    customer_id BIGINT NOT NULL REFERENCES customer(id),
    total       NUMERIC(10,2) NOT NULL
);

CREATE TABLE account (
    id      BIGSERIAL PRIMARY KEY,
    owner   TEXT NOT NULL,
    balance NUMERIC(12,2) NOT NULL CHECK (balance >= 0)
);

Source: schema.sql

# Boot runs schema.sql through the R2DBC ConnectionFactory (not JDBC) at startup.
spring.sql.init.mode=always
spring.r2dbc.pool.initial-size=5
spring.r2dbc.pool.max-size=20
logging.level.org.springframework.r2dbc=INFO

Source: application.properties

The pool settings matter more than they look: with R2DBC the pool size, not the thread count, is what limits concurrent database work. The test in the next section confirms Boot ran the script:

tables created by schema.sql: [account, customer, purchase_order]

Real output: 01-schema-init.txt

Going deeper: schema initialisation and migrations

spring.sql.init.mode=always is right for demos and tests. For a real service use a migration tool. Flyway and Liquibase are JDBC-based, so a reactive app that uses them needs a JDBC driver on the classpath purely for migrations, which is a common and reasonable compromise. My schema.sql starts with DROP TABLE IF EXISTS so reruns are clean; never ship that to a database you care about.

CRUD with ReactiveCrudRepository

An entity is a plain class or record with an @Id. There is no @Entity, no EntityManager, no persistence context and no dirty checking. A save is an INSERT when the id is null and an UPDATE otherwise — nothing more clever.

@Table("customer")
public record Customer(@Id Long id, String name, String email, String tier) {
    public static Customer ofNew(String name, String email, String tier) {
        return new Customer(null, name, email, tier);
    }
}

Source: Customer.java

The repository interface looks like Spring Data JPA, with reactive return types:

public interface CustomerRepository extends ReactiveCrudRepository<Customer, Long> {
    // save, saveAll, findById, findAll, count, deleteById ... all come for free
}

Abridged from: CustomerRepository.java

Testing reactive code means asserting on a stream of signals rather than on a return value. StepVerifier from reactor-test subscribes for you and checks each signal. The test below runs against a real Postgres (see the Testcontainers section) and writes the transcript beneath it.

    void crudWithStepVerifier() {
        Transcript t = new Transcript("02-crud.txt");

        StepVerifier.create(customers.save(Customer.ofNew("Asha Rao", "[email protected]", "GOLD")))
                .assertNext(c -> { assertNotNull(c.id()); t.line("save      -> id=%d name=%s", c.id(), c.name()); })
                .verifyComplete();

        Customer asha = customers.findByEmail("[email protected]").block();
        StepVerifier.create(customers.findById(asha.id()))
                .assertNext(c -> t.line("findById  -> %s", c))
                .verifyComplete();

        StepVerifier.create(customers.save(new Customer(asha.id(), "Asha Rao-Iyer", asha.email(), "GOLD")))
                .assertNext(c -> t.line("update    -> name=%s (same id=%d, SQL UPDATE)", c.name(), c.id()))
                .verifyComplete();

        StepVerifier.create(customers.count()).expectNext(1L).verifyComplete();
        t.line("count     -> 1");

        StepVerifier.create(customers.deleteById(asha.id())).verifyComplete();
        StepVerifier.create(customers.count()).expectNext(0L).verifyComplete();
        t.line("deleteById-> count now 0");

        StepVerifier.create(customers.findById(9999L)).verifyComplete();
        t.line("findById(9999) -> empty Mono (completes with no value, no exception)");
        t.write();
    }

Source: R2dbcDemoTest.java

save      -> id=1 name=Asha Rao
findById  -> Customer[id=1, name=Asha Rao, [email protected], tier=GOLD]
update    -> name=Asha Rao-Iyer (same id=1, SQL UPDATE)
count     -> 1
deleteById-> count now 0
findById(9999) -> empty Mono (completes with no value, no exception)

Real output: 02-crud.txt

Two details are visible in that output. A findById for a missing row completes empty — no exception, no null — so “not found” is something you handle with switchIfEmpty, not try/catch. And a record works as an entity, so an update means building a new record with the same id.

The trap: nothing happens until you subscribe

Remember the picture of a repository method as a description of a query. This test builds a save pipeline and never subscribes to it:

    void nothingHappensUntilYouSubscribe() {
        Transcript t = new Transcript("03-lazy-pipeline.txt");
        Mono<Customer> unsubscribed = customers.save(Customer.ofNew("Ghost", "[email protected]", "STANDARD"));
        long before = customers.count().block();
        t.line("built save(...) pipeline, never subscribed -> rows in table: %d", before);
        assertEquals(0L, before);
        unsubscribed.block();
        long after = customers.count().block();
        t.line("after .block() on the same pipeline        -> rows in table: %d", after);
        assertEquals(1L, after);
        t.write();
    }

Source: R2dbcDemoTest.java

built save(...) pipeline, never subscribed -> rows in table: 0
after .block() on the same pipeline        -> rows in table: 1

Real output: 03-lazy-pipeline.txt

This is the most common R2DBC bug. A controller that calls repository.save(x) and ignores the return value compiles, runs, returns 200 — and writes nothing. In a WebFlux controller you return the Mono and the framework subscribes. In a service method that does not return it, you have to chain it or subscribe yourself. If data is “mysteriously not saved”, look for a dropped Mono first.
Going deeper: what save() actually does

save() decides insert versus update from the id: a null id (or a primitive zero) means “new”. If you assign ids yourself (for example UUIDs), Spring Data cannot tell a new entity from an existing one and will issue an UPDATE that affects zero rows. The fix is to implement Persistable with an isNew() method, or use an @Version field. I did not test the assigned-id case in the companion repo; this is from the Spring Data reference documentation, not from a run.

saveAll() is not a batch insert in the JDBC sense; it saves each element in sequence on one subscription. For real bulk loading use DatabaseClient with a multi-row INSERT or COPY via the driver.

Custom queries: derived, @Query and DatabaseClient

Three levels, from least to most control. First, derived queries: Spring Data builds the SQL from the method name. Second, @Query with plain SQL (not JPQL — there is no object query language here). Third, DatabaseClient when you want to own the SQL and the row mapping completely.

    /** Derived query: Spring Data builds the SQL from the method name. */
    Flux<Customer> findByTier(String tier);

    Mono<Customer> findByEmail(String email);

    /** Hand-written SQL: R2DBC repositories take plain SQL, not JPQL. */
    @Query("SELECT * FROM customer WHERE lower(name) LIKE lower(:pattern) ORDER BY name")
    Flux<Customer> searchByName(String pattern);

    /** Modifying query: must return {@code Mono<Long>} (rows updated), {@code Mono<Boolean>} or {@code Mono<Void>}. */
    @org.springframework.data.r2dbc.repository.Modifying
    @Query("UPDATE customer SET tier = :tier WHERE id = :id")
    Mono<Long> changeTier(Long id, String tier);
}

Source: CustomerRepository.java

One rule is easy to miss: a @Query that changes data needs @Modifying, and its return type tells you how many rows it touched (Mono<Long>), whether any did (Mono<Boolean>), or nothing (Mono<Void>). For SQL that does not map to an entity, such as an aggregate, DatabaseClient is the right tool:

    /** DatabaseClient with a named parameter and an aggregate - SQL stays SQL. */
    public Mono<BigDecimal> totalSpent(String email) {
        return db.sql("""
                        SELECT COALESCE(SUM(o.total), 0) AS spent
                        FROM customer c JOIN purchase_order o ON o.customer_id = c.id
                        WHERE c.email = :email
                        """)
                .bind("email", email)
                .map(row -> row.get("spent", BigDecimal.class))
                .one();
    }

Source: CustomerOrderService.java

findByTier(GOLD)        -> [Asha Rao, Meera Joshi]
@Query searchByName(%RAO%) -> [Asha Rao]
@Modifying changeTier    -> 1 row updated
GOLD customers now       -> 3
DatabaseClient totalSpent(ravi) -> 200.00

Real output: 04-queries.txt

All of these go through the same pooled connections, so they can also join the same transaction, which comes next. Note that parameters are bound by name (:email) and sent separately from the SQL text, so this is not string concatenation and not an injection risk.

Going deeper: DatabaseClient versus R2dbcEntityTemplate

There are really four layers: repositories, R2dbcEntityTemplate (a fluent API that still maps to entities and builds criteria for you), DatabaseClient (SQL in, rows out) and the raw ConnectionFactory. I used the first and third because they cover most needs; I did not exercise R2dbcEntityTemplate in the companion repo. Use it when you need dynamic criteria that a derived method name cannot express.

Row mapping in DatabaseClient is a lambda over the driver’s Row. Types come back as the driver decides: Postgres NUMERIC arrives as BigDecimal when you ask for it, as in the example above.

No lazy loading, no relations: what that costs you

If you come from JPA, this is the biggest change. A Customer cannot have a List<PurchaseOrder> field that Spring populates. There is no @OneToMany, no @ManyToOne, and therefore no lazy loading, because there is no persistence context to load things later. Spring Data R2DBC maps one row to one object, and a foreign key is just a number. Both entities in the companion repo are flat:

@Table("purchase_order")
public record PurchaseOrder(@Id Long id, @Column("customer_id") Long customerId, BigDecimal total) {
}

Source: PurchaseOrder.java

The consequence is that “customers with their orders” is something you assemble, and you have to pick how many queries to spend. The service has all three honest options, and a test proves they return identical results:

Same result, three round-trip budgets1 + Ncustomersorders of #1orders of #2orders of #34 statementstwo queriescustomersall orderszip in memory (a Map)2 statementsone JOINcustomer LEFT JOIN purchase_ordergroup rows by customer id1 statementWhat to take from this: JPA hides this choice behind lazy loading. R2DBC has no such layer,so you choose the budget explicitly in every query. Nothing is lazy, nothing is surprising.

The diagram is the whole argument: JPA lets you not decide (and sometimes you pay for it later as the N+1 problem); R2DBC forces the decision into your code. The first strategy is the shortest to write and the worst to run; the JOIN is the most work and the fastest. The hand-grouped JOIN version looks like this:

    /** Way 3: one JOIN with DatabaseClient, grouped by hand. */
    public Flux<CustomerWithOrders> loadWithJoin() {
        return db.sql("""
                        SELECT c.id AS cid, c.name, c.email, c.tier,
                               o.id AS oid, o.total
                        FROM customer c LEFT JOIN purchase_order o ON o.customer_id = c.id
                        ORDER BY c.id, o.id
                        """)
                .map((row, meta) -> new Object[]{
                        new Customer(row.get("cid", Long.class), row.get("name", String.class),
                                row.get("email", String.class), row.get("tier", String.class)),
                        row.get("oid", Long.class),
                        row.get("total", BigDecimal.class)})
                .all()
                .bufferUntilChanged(r -> ((Customer) r[0]).id())
                .map(group -> {
                    Customer c = (Customer) group.get(0)[0];
                    List<PurchaseOrder> os = group.stream()
                            .filter(r -> r[1] != null)
                            .map(r -> new PurchaseOrder((Long) r[1], c.id(), (BigDecimal) r[2]))
                            .toList();
                    return new CustomerWithOrders(c, os);
                });
    }

Source: CustomerOrderService.java

findAll() on Customer returns rows only. There is no orders field to be lazy or eager:
  Customer[id=1, name=A, [email protected], tier=STANDARD]
1+N queries (4 SQL statements for 3 customers) -> [A:2, B:1, C:0]
two queries (2 SQL statements)                -> [A:2, B:1, C:0]
one LEFT JOIN, grouped by hand (1 statement)   -> [A:2, B:1, C:0]
all three strategies return identical aggregates: true

Real output: 05-no-relations.txt

Honest limit of this transcript. The statement counts in the output (4, 2 and 1) are true by construction of the three methods; I did not instrument the driver to count statements on the wire. What the test does assert, and the output shows, is that all three return the same aggregates.
Going deeper: what you do get

Spring Data R2DBC does support value-object style mapping with @Embedded, custom converters, @Column, @Version for optimistic locking and auditing annotations. It does not support relationships. Aggregate-style persistence of children (as in Spring Data JDBC’s @MappedCollection) is not available in the reactive module either, which I confirmed only from the reference documentation, not by trying to compile it.

If you find yourself writing the grouping code in every query, that is a signal to look at whether the read model should be a database view or a JSON aggregate (json_agg) returned in one column.

Reactive transactions that actually roll back

A JDBC transaction is bound to a thread: Spring stores the connection in a ThreadLocal. That cannot work when one request hops between threads, so reactive transactions are bound to the subscription instead, carried in Reactor’s context. The practical effect: @Transactional still works, but only on methods that return a Mono or Flux, and the transaction lives exactly as long as the returned pipeline.

Debit succeeds, then something throwsno transactionUPDATE alice -40error thrownalice = 60, bob = 50 (money lost)@TransactionalUPDATE alice -40error thrownROLLBACK: alice = 100, bob = 50What to take from this: the transaction is a property of the Mono you return, not of the thread.The error signal travelling down the pipeline is what triggers the rollback.

The diagram shows the experiment run below: move 40 from Alice to Bob, but throw after the debit. Without a transaction the debit has already committed, so 40 simply disappears. With one, the error signal triggers a rollback. There are two ways to ask for the transaction. Declaratively with @Transactional on the service method, and programmatically with TransactionalOperator wrapping exactly the pipeline you pass it:

    /** Declarative: the returned Mono is wrapped in a transaction by the proxy. */
    @Transactional
    public Mono<Void> transfer(long from, long to, BigDecimal amount) {
        return adjust(from, amount.negate())
                .then(adjust(to, amount))
                .then();
    }

Source: AccountService.java

    /** Programmatic: TransactionalOperator wraps exactly the pipeline you hand it. */
    public Mono<Void> transferWithOperator(long from, long to, BigDecimal amount, boolean failAfterDebit) {
        Mono<Void> work = adjust(from, amount.negate())
                .then(failAfterDebit
                        ? Mono.<Long>error(new IllegalStateException("simulated failure after debit"))
                        : Mono.just(0L))
                .then(adjust(to, amount))
                .then();
        return tx.transactional(work);
    }

Source: AccountService.java

NO transaction, failure after debit  -> alice=60.00 bob=50.00  (money vanished)
@Transactional, failure after debit  -> alice=100.00 bob=50.00  (rolled back)
TransactionalOperator, failure       -> alice=100.00 bob=50.00  (rolled back)
TransactionalOperator, success       -> alice=60.00 bob=90.00  (committed)
@Transactional, CHECK(balance>=0) violated on debit -> DataIntegrityViolationException; alice=100.00 bob=50.00
transfer to a non-existent account   -> no error at all; alice=90.00 (debited, credit hit 0 rows)

Real output: 06-transactions.txt

Read the six lines in order. Line one is the no-transaction failure (Alice 60, Bob 50: money vanished). Lines two and three show both mechanisms rolling back to 100/50. Line four is the success path committing. Line five is the database itself rejecting the debit (my CHECK (balance >= 0) constraint) and Spring translating it to DataIntegrityViolationException, with nothing changed. Line six is a reminder that rollback only protects you from errors.

The last line is the quiet bug. Transferring to an account that does not exist raised no error: the credit updated zero rows, which is a success as far as SQL is concerned, and the transaction committed the debit. A transaction guarantees atomicity of what you did, not correctness of what you meant. Check the rowsUpdated count and fail explicitly if it is not 1.
Going deeper: traps with reactive transactions
  • Return the Mono. A @Transactional method that subscribes internally and returns void gets no transaction, for the same reason as the lazy-pipeline trap above.
  • Self-invocation bypasses the proxy exactly as with JPA: calling this.transfer(..) from another method of the same class is not transactional. This is standard Spring AOP behaviour; I did not add a test for it here.
  • Do not block inside. Calling .block() inside a pipeline on an event-loop thread throws at runtime in Reactor, by design (Reactor behaviour from its documentation; not exercised in the companion repo).
  • Isolation and read-only attributes work on @Transactional and are passed to the driver, but I only tested the defaults.

Testing with Testcontainers 2.x and StepVerifier

Do not test R2DBC code against H2 and hope. SQL dialects, constraint behaviour and type mapping differ, and the whole point of the previous section was that a Postgres CHECK constraint rolled something back. Run the real database. With Spring Boot’s @ServiceConnection the container’s host and port are wired into the R2DBC ConnectionFactory automatically:

@Testcontainers
@SpringBootTest
class R2dbcDemoTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");

    @Autowired CustomerRepository customers;
    @Autowired PurchaseOrderRepository orders;
    @Autowired CustomerOrderService service;
    @Autowired AccountService accounts;
    @Autowired DatabaseClient db;

Source: R2dbcDemoTest.java

Two Testcontainers 2.x details cost me a failing context load while writing this. In 2.x the module artifact ids are prefixed (testcontainers-postgresql, testcontainers-junit-jupiter) and the container class moved to org.testcontainers.postgresql.PostgreSQLContainer. And, specific to R2DBC, you must also add testcontainers-r2dbc; without it the app context fails at the connection factory with the error below, which does not mention Testcontainers in the message you will search for first.

mvn test with testcontainers-r2dbc removed from the pom:
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 57.47 s <<< FAILURE! -- in com.ankurm.r2dbc.R2dbcDemoTest
Failed to instantiate [io.r2dbc.pool.ConnectionPool]: Factory method 'connectionFactory' threw exception with message: org/testcontainers/r2dbc/R2DBCDatabaseContainer
Caused by: java.lang.ClassNotFoundException: org.testcontainers.r2dbc.R2DBCDatabaseContainer

Real output: 08-missing-testcontainers-r2dbc.txt

Here is the dependency set that works, taken from the module’s pom:

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-testcontainers</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers-postgresql</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers-r2dbc</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers-junit-jupiter</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>io.projectreactor</groupId>
      <artifactId>reactor-test</artifactId>
      <scope>test</scope>
    </dependency>

Source: pom.xml

Going deeper: StepVerifier habits and test isolation

Prefer StepVerifier.create(...).expectNext(...).verifyComplete() over .block() in assertions: it also checks that the stream completes and that no extra elements or errors follow. Use .block() only in test setup, as @BeforeEach does here to truncate tables. For virtual time (delays, timeouts) use StepVerifier.withVirtualTime; I did not need it for database tests.

The container is static, so it is started once for the class and shared; the tests isolate themselves by truncating tables before each run rather than paying for a container per test.

R2DBC or virtual threads plus JPA? The honest decision

The earlier benchmark post found that for a typical I/O-bound web endpoint, virtual threads on Spring MVC get you most of what WebFlux promised, without rewriting your code in a different style. The same logic applies one layer down. Before choosing R2DBC, ask what problem it would solve that a virtual thread does not. Since JDK 21 a blocking JDBC call parks a cheap virtual thread instead of an expensive platform thread, so “threads are scarce” is no longer the reason to go reactive. In Spring Boot you opt in with spring.threads.virtual.enabled=true.

Here is a small measurement from the companion repo. It sends the same primary-key SELECT to the same Postgres through R2DBC DatabaseClient and through plain JDBC (HikariCP and JdbcTemplate) on virtual threads, with the same pool size (20) and the same number in flight (100):

INDICATIVE, shared 2-vCPU sandbox, database in a container on the same host.
4000 x 'SELECT ... WHERE id = ?' per round, 100 in flight, pool size 20, 5 rounds after 1 warm-up each.
queries/second, sorted:
  R2DBC DatabaseClient      : [233, 288, 361, 427, 435]  median=361
  JDBC + virtual threads    : [1240, 1281, 1348, 1558, 3084]  median=1348

Real output: 07-throughput-indicative.txt

Read this carefully, because it is easy to over-read. The harness is ThroughputComparisonTest.java. It ran on a shared 2-vCPU sandbox where the database container, the JVM and other unrelated builds were all competing for the same CPUs (load average around 30), so this is indicative only. In this run JDBC on virtual threads was faster in every round and the ranges did not overlap, which is the opposite of the “reactive is faster” folklore. I did not profile why, and I am not claiming R2DBC is generally slower: the likely contributors (driver codec overhead, Reactor and Spring layers, event-loop threads competing for two CPUs) are guesses. The defensible conclusion is narrow: for plain request/response queries, R2DBC did not buy throughput here, and both models are capped by the same 20 connections.

What it does not measure. Memory under thousands of idle waiting requests, streaming a large result with backpressure, or composing many concurrent calls per request. Those are where reactive can still win, and a point-select loop says nothing about them.

Choose virtual threads with JPA or JDBC when…

  • You have a normal CRUD service. Relations, lazy loading, Specifications, Envers, second-level caching and the JPA ecosystem are real productivity. The relations section above showed what you give up.
  • You are starting a new project without a reason not to. Plain blocking code is easier to read, step through in a debugger and read in a stack trace. Your team probably already knows it.
  • You rely on ThreadLocal-based tooling (security context, MDC logging, many libraries). They work as before on virtual threads; in reactive code they need explicit context propagation.
  • You need mature database support and migrations. JDBC drivers exist for everything and Flyway/Liquibase use them directly.

Choose R2DBC when…

  • Your service is already WebFlux end to end. One blocking JDBC call on an event-loop thread stalls every request on it, so a reactive web layer wants a non-blocking data layer. This is the strongest reason, and it is a consistency reason, not a speed reason.
  • You stream rows to a client (SSE, large exports) and want demand-driven backpressure from database to socket. The benchmark post covers why this is structurally easier with Flux.
  • You compose many independent I/O calls per request (database plus several HTTP calls) and the reactive operators (zip, flatMap with a concurrency limit, timeouts, retries) fit the problem.
  • Your team is fluent in Reactor. The model has a real learning curve, as the “nothing runs until you subscribe” bug above shows.
QuestionVirtual threads + JPA/JDBCR2DBC
Code styleBlocking, sequentialReactive pipelines
Relations and lazy loadingYes (JPA)No, assemble yourself
TransactionsThread-bound, familiarBound to the subscription
Backpressure to the clientManualBuilt in
Debugging and stack tracesPlainHarder
Fits a WebFlux stackOnly off the event loopNatively
Should you even do this? If you are not already committed to WebFlux, my default is virtual threads with JPA or Spring Data JDBC, and R2DBC only for a specific streaming or composition need. R2DBC is a working, maintained option (the Postgres driver shipped a release in September 2026), but its SPI has been at 1.0.0 since April 2022 and the ecosystem around it is smaller than the JDBC ecosystem. Choosing it to “be more scalable” is the weakest reason there is. Choosing it because the rest of your stack is reactive and must not block is the strongest.
Going deeper: the one JDBC caveat worth knowing

Virtual threads do not make the database faster. Your connection pool is still the real limit (20 in the test above), and once more requests are waiting than the pool has connections, you queue either way. If a library holds a lock around blocking I/O you can still hurt yourself; JDK 24 removed the main historical cause of virtual-thread pinning (synchronized blocks), which I am quoting from JEP 491, not from a test in this repo. If your JDBC driver is old, check its release notes.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.