Skip to content

Virtual Threads Part 3: Why JDBC Beat R2DBC More Often Than Expected

Editorial illustration of JDBC work running on virtual threads beside reactive database work
Database performance is often decided at the connection, transaction, and backpressure boundaries.

This is Part 3 of the Virtual Threads series. The full series continues with Part 1: introduction and cautions, Part 2: workshop rules, Part 3: JDBC + Virtual Threads benchmark, and Part 4: Java 21/25 SPI design.

Part 1 framed Virtual Threads as “threads that make waiting-heavy work easier to express,” not as “faster threads.” Now we can ask a more practical question.

If we already have R2DBC + Coroutines, can JDBC + Virtual Threads really win?

In the bluetape4k-exposed benchmarks, the answer leaned in one direction. I expected R2DBC to look stronger: it is non-blocking, and Coroutines make the API pleasant. The measurements were less kind to that assumption. General CRUD/JOIN benchmarks improved substantially when the JDBC path was tuned around pool size and indexes, and batch benchmarks often favored JDBC + Virtual Threads over R2DBC + Coroutines for the same workload.

Looking only at batch results would make the conclusion too narrow. bluetape4k-exposed also has a general CRUD/JOIN benchmark, ExposedJdbcBenchmark, under exposed/exposed-jdbc/src/test/kotlin/.../ExposedJdbcBenchmark.kt.

It measures five methods on PostgreSQL Testcontainers through kotlinx-benchmark/JMH.

MethodMeaning
singleInsertSingle-row INSERT
singleFindByIdPrimary-key SELECT
singleUpdateSingle-row UPDATE
joinQueryusers/orders JOIN query
batchInsert100-row batch INSERT

The initial total was 25,400.673 ops/sec. A self-improve loop adjusted HikariCP and benchmark thread count, then added indexes for the JOIN target table.

StepTotal throughput
Baseline25,400 ops/sec
HikariCP max=24, minIdle=8, @Threads(14)43,487 ops/sec
Add orders-table index44,161 ops/sec
Highest point after stronger measurement settings45,431 ops/sec
General Exposed JDBC benchmark throughput chart from baseline to tuned settings
The ordinary Exposed JDBC path improved from 25.4k to 45.4k ops/sec after pool/thread tuning and JOIN indexes.

This is not a direct JDBC-vs-R2DBC comparison. It is evidence that the ordinary Exposed JDBC path is sensitive to pool size, thread concurrency, and index design. In other words, measure the basic path before assuming Virtual Threads will rescue anything. A more aggressive synchronous_commit=off style tuning was rejected because it improved some inserts while hurting SELECTs badly. A good benchmark cannot make one number pretty while damaging the whole workload.

The benchmark method is intentionally ordinary.

@Benchmark
@Threads(14)
open fun singleFindById(): Int {
val pk = (findIdSeq.getAndIncrement() % SEED_USERS) + 1
return transaction(database) {
BenchmarkUsers
.selectAll()
.where { BenchmarkUsers.id eq pk }
.count()
.toInt()
}
}

The direct comparison lives in bluetape4k-exposed/utils/batch/benchmark.

AxisValue
DatabasesH2, MySQL, PostgreSQL
JDBC pathJDBC + Virtual Threads
R2DBC pathR2DBC + Coroutines
ScenariosseedBenchmark, endToEndBatchJobBenchmark
ParametersdataSize = 1000/10000/100000, poolSize = 10/30/60, parallelism = 1/4/8

Entrypoints look like this.

Terminal window
./gradlew :exposed-batch:mysqlJdbcBenchmark
./gradlew :exposed-batch:mysqlR2dbcBenchmark
./gradlew :exposed-batch:postgresJdbcBenchmark
./gradlew :exposed-batch:postgresR2dbcBenchmark

The meaning is not “JDBC is always better.” It is narrower and more useful.

  • The workload is batch insert and batch job oriented.
  • The comparison includes actual Exposed/JDBC and Exposed/R2DBC implementation cost.
  • It is not a coroutine microbenchmark.
  • Driver maturity and DB-specific protocol paths are part of the result.

That makes it an end-to-end comparison from the application’s point of view.

By ops/sec, JDBC won this many combinations.

DBComparisonsJDBC winsR2DBC winsLarge batch E2E parallelism=8
MySQL36360JDBC about 8.4-8.8x
PostgreSQL36333JDBC about 4.9-5.2x
H236360JDBC strongly ahead, but in-memory caveat applies

The MySQL seed benchmark was especially wide. At dataSize=100000, JDBC reached about 1.455 ops/sec; R2DBC was about 0.050 ops/sec, roughly a 29x difference.

For the large end-to-end job, the result is easier to read.

JDBC vs R2DBC batch benchmark chart for large end-to-end jobs
Large end-to-end batch jobs favored JDBC + Virtual Threads by about 8.4-8.8x on MySQL and 4.9-5.2x on PostgreSQL.
DBpoolSizeJDBC ops/secR2DBC ops/secJDBC/R2DBC
MySQL101.5960.1818.8x
MySQL301.5610.1828.6x
MySQL601.5250.1828.4x
PostgreSQL100.9720.1925.1x
PostgreSQL300.9900.1925.2x
PostgreSQL600.9510.1934.9x

PostgreSQL pointed the same way. JDBC won 33 of 36 combinations, and in the large end-to-end job with parallelism=8 it was about 5x higher than R2DBC.

H2 was even more extreme, but H2 is in-memory and should not be treated as a direct network DB lesson. It is better read as a supporting signal: when JDBC overhead is low, Virtual Threads can drive a simple path effectively.

R2DBC + Coroutines looks more modern on paper. It wraps callbacks in suspend APIs, uses fewer threads, and multiplexes more work through non-blocking I/O. That is real. It is also not the only cost in a batch workload.

CostExplanation
driver maturityJDBC batch paths have been tuned for a long time
protocol round-tripnon-blocking does not remove round-trips
mapping overheadreactive stream/suspend bridge cost is part of end-to-end time
backpressure granularityrow-level backpressure can be excessive for batch jobs
connection pool behaviorDB concurrency is still bounded by connections and server capacity

Virtual Threads help because the JDBC call may block, but it does not block an expensive platform thread. That lets us keep the fast JDBC batch path and still raise concurrency.

Executors.newVirtualThreadPerTaskExecutor().use { executor ->
partitions.forEach { partition ->
executor.submit {
transaction {
processPartition(partition)
}
}
}
}

The code does not look special. That is a feature. Transaction boundaries, exceptions, stack traces, and profilers still look familiar.

Similar Direction to Coroutine Performance Gains

Section titled “Similar Direction to Coroutine Performance Gains”

Virtual Threads and Coroutines solve a related problem: work that is waiting should not tie up expensive carrier resources for too long. That is why they can both improve I/O-heavy workloads.

The implementation tradeoffs differ.

AspectCoroutinesVirtual Threads
API shapesuspend propagates through the APIBlocking APIs can stay
DB accessFits async drivers such as R2DBCFits blocking drivers such as JDBC
toolingCoroutine debugger/context mattersThread dumps/JFR/thread stacks remain familiar
migrationAPI surface changesCall stacks change less

What the bluetape4k-exposed benchmark made clear is that “async is theoretically more scalable” is not enough. Driver and workload matter. In this batch workload, JDBC + Virtual Threads was both simpler and faster.

This is not an argument to delete R2DBC. It is the better fit in plenty of cases.

  • The whole stack is already reactive or suspend-first.
  • Streaming responses and backpressure are core domain concerns.
  • The database driver’s R2DBC path is fast for the actual workload.
  • Reactive pipeline observability is more familiar than thread-per-request observability.
  • Transactions are short and rows should be processed as a stream.

But “R2DBC must be faster” is no longer a safe assumption. For batch, insert-heavy, or transaction-heavy workloads, JDBC + Virtual Threads deserves a measurement.

My rule from this result is:

SituationTry first
legacy JDBC code already existsJDBC + Virtual Threads
many batch inserts/updatesJDBC + Virtual Threads benchmark
service API is suspend-firstR2DBC + Coroutines
streaming/backpressure is centralR2DBC + Coroutines
performance claim mattersMeasure both on the same workload

Virtual Threads put JDBC back into the “worth measuring” category. The JDBC retirement party can wait a little longer. For Kotlin/JVM backend services, that is very practical: you can keep existing blocking code and improve throughput without converting every API to suspend.

Part 4 shows how to expose that choice as a library API. Java 21 and Java 25 both belong to the Virtual Threads era, but their available APIs differ, so bluetape4k-projects separates common API from JDK-specific implementations.

Comments

Leave a note or reaction with your GitHub account.