Bluetape4k Graph Part 5: Reading the Virtual Threads Benchmark

bluetape4k-graph exposes both sync and virtual-thread APIs. That immediately raises a practical question:
Graph database calls look I/O-bound. Should Virtual Threads be the default?
No. For TinkerGraph in-memory operations that finish in a few microseconds, sync is faster. Wrapping the call adds
CompletableFuture creation, virtual-thread scheduling, and join() cost. For tiny operations, that cost is visible.
For Neo4j, Memgraph, or AGE over a real network connection, the decision may change: blocking wait can be much larger
than the wrapping cost, but the effect still requires a concurrent workload measurement.
Virtual Threads improve scale rather than the speed of an individual task. This article explains when wrapping is wasted work, which conclusions the preserved TinkerGraph result supports, and what a production-backend benchmark must still measure.
Same graph work, different execution path
Section titled “Same graph work, different execution path”GraphOperations is synchronous. The virtual-thread facade does not implement a different backend. It runs the same
sync work on a virtual thread and returns a CompletableFuture<T>.

The adapter is intentionally simple.
class VirtualThreadVertexAdapter( private val delegate: GraphVertexRepository,) : GraphVirtualThreadVertexRepository {
override fun findVertexByIdAsync( label: String, id: GraphElementId, ): CompletableFuture<GraphVertex?> = virtualFutureOfNullable { delegate.findVertexById(label, id) }}The delegated findVertexById() call is still synchronous. Whether that is cheap or expensive depends on the backend.
Read the benchmark setup first
Section titled “Read the benchmark setup first”The numbers below are the preserved 2026-04-17 results from the graph-benchmark module and its TinkerGraph fixture.
The current source retains the same annotations, but this proofreading pass did not rerun the benchmark.
| Item | Value |
|---|---|
| Backend | TinkerGraph in-memory |
| JVM | Java 25 |
| Measurement | JMH AverageTime, µs/op |
| Warmup / measurement | 3 × 2s / 5 × 3s |
| Comparison | sync call versus virtual-thread adapter + CompletableFuture.join() |
GraphBenchmarkState creates four Person vertices and KNOWS/FOLLOWS edges, then compares sync and virtual-thread
calls against the same graph.
@Benchmarkfun syncFindVertexById(): Boolean = syncOps.findVertexById("Person", aliceId) != null
@Benchmarkfun vtFindVertexById(): Boolean = vtOps.findVertexByIdAsync("Person", aliceId).join() != nullThis setup intentionally removes network noise. It exposes end-to-end adapter overhead, not production Neo4j latency.
Each benchmark creates the future and immediately calls join(), so it does not measure concurrent request throughput.
The numbers are direct
Section titled “The numbers are direct”On the TinkerGraph in-memory fixture, sync wins every single-call benchmark.

Representative AverageTime results:
| Benchmark | Sync µs/op | Virtual Thread µs/op | Interpretation |
|---|---|---|---|
findVertexById | 1.758 | 8.055 | Wrapping cost dominates a tiny lookup |
findVerticesByLabel | 2.485 | 8.679 | The query is too small for VT to help |
neighbors | 2.985 | 10.595 | Small traversal still favors sync |
bfs | 4.216 | 12.588 | Small fixture keeps algorithm work cheap |
pageRank | 7.270 | 15.257 | More work reduces the ratio but not the overhead |
shortestPath | 22.031 | 31.540 | Larger work narrows the relative gap |
The useful observation is the absolute difference. The preserved note summarizes the virtual-thread path as roughly
6-8µs more expensive under this JVM, fixture, adapter, and immediate-join() shape. That number is not a universal
constant. If a real graph call waits several milliseconds, its relative weight may shrink, but connection-pool wait,
server capacity, query planning, error rate, and concurrent throughput still require measurement.
When sync is the better default
Section titled “When sync is the better default”Use sync when the work is already short and blocking is not the bottleneck.
| Situation | Why |
|---|---|
| TinkerGraph or another in-memory graph | The call is too small for wrapping to pay off |
| One-off simple lookup | A CompletableFuture adds little value |
| Low request volume | Blocking is not currently the scaling limit |
| Benchmark fixtures and unit tests | The smallest execution path is usually clearer |
For small in-memory graph work, sync is simpler, faster, and easier to debug.
When Virtual Threads are useful
Section titled “When Virtual Threads are useful”The virtual-thread API makes sense when waiting dominates.
| Situation | Why |
|---|---|
| Neo4j, Memgraph, or AGE over a network | Compatible blocking I/O can release the carrier thread while the virtual thread waits |
| Spring MVC or existing blocking service code | You can retain a thread-per-request model without spreading suspend |
| Many concurrent graph requests | Each waiting task can use a lightweight virtual thread |
Java callers that want CompletableFuture | Async composition works without coroutines |
val vtOps = graphOperations.asVirtualThread()
val friends = vtOps .neighborsAsync(userId, NeighborOptions(edgeLabel = "FOLLOWS")) .thenApply { vertices -> vertices.map { it.id } }Calling join() immediately still waits in the current flow. Submit independent work before combining it with
CompletableFuture.allOf(...); express dependent stages with thenCompose or thenApply. The graph server,
connection pool, and target concurrency remain the limiting resources to measure.
Connect it to backend choice
Section titled “Connect it to backend choice”This benchmark also sharpens the graph DB selection guide.
| Backend | How to read the execution model |
|---|---|
| TinkerGraph | Usually sync for fixtures, prototypes, and tests |
| Neo4j | Measure the real driver under target concurrency when network wait is material |
| Memgraph | Include network wait and target concurrency even when single-call latency is low |
| Apache AGE | Measure PostgreSQL JDBC wait together with connection-pool limits |
The benchmark is not a leaderboard or proof that network backends benefit. It turns two questions into measurements: whether backend wait is meaningfully larger than adapter overhead, and whether throughput improves at target concurrency.
Takeaway
Section titled “Takeaway”The virtual-thread API in bluetape4k-graph is not a replacement for sync. It is an execution-model option on the same
graph abstraction.
- For in-memory, microsecond-scale, one-off calls, sync is simpler and faster.
- For blocking network graph calls under concurrency, Virtual Threads are a candidate that must be measured.
- The preserved TinkerGraph numbers show adapter overhead, not production-backend throughput.
- A future that is immediately joined is different from a future that is composed.
Performance choices are not made by API names. They are made by finding where the code waits.
Sources
Section titled “Sources”- Virtual Thread vs Sync benchmark result
- Graph DB tradeoffs
- Virtual Threads expansion design
- Virtual Threads expansion plan
- GraphBenchmarkState
- VertexOperationsBenchmark
- TraversalBenchmark
- AlgorithmBenchmark
- ApiModelBenchmark
- VirtualThreadVertexAdapter
- VirtualThreadOperationsAdapter
- OpenJDK JEP 444: Virtual Threads
Comments
Leave a note or reaction with your GitHub account.