Skip to content

Bluetape4k Graph Part 5: Reading the Virtual Threads Benchmark

Robotic builders inspecting Sync and Virtual Threads graph benchmark lanes on a 3D workbench
Virtual Threads show their value in waiting code, not in tiny in-memory computations.

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.

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

Sync path and Virtual Thread path for the same graph backend operation
The virtual-thread path changes where the wait happens. It does not make the backend work disappear.

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.

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.

ItemValue
BackendTinkerGraph in-memory
JVMJava 25
MeasurementJMH AverageTime, µs/op
Warmup / measurement3 × 2s / 5 × 3s
Comparisonsync 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.

@Benchmark
fun syncFindVertexById(): Boolean =
syncOps.findVertexById("Person", aliceId) != null
@Benchmark
fun vtFindVertexById(): Boolean =
vtOps.findVertexByIdAsync("Person", aliceId).join() != null

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

On the TinkerGraph in-memory fixture, sync wins every single-call benchmark.

TinkerGraph benchmark chart comparing Sync and Virtual Threads latency in microseconds per operation
When the operation itself is only a few microseconds, virtual-thread wrapping cost is visible.

Representative AverageTime results:

BenchmarkSync µs/opVirtual Thread µs/opInterpretation
findVertexById1.7588.055Wrapping cost dominates a tiny lookup
findVerticesByLabel2.4858.679The query is too small for VT to help
neighbors2.98510.595Small traversal still favors sync
bfs4.21612.588Small fixture keeps algorithm work cheap
pageRank7.27015.257More work reduces the ratio but not the overhead
shortestPath22.03131.540Larger 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.

Use sync when the work is already short and blocking is not the bottleneck.

SituationWhy
TinkerGraph or another in-memory graphThe call is too small for wrapping to pay off
One-off simple lookupA CompletableFuture adds little value
Low request volumeBlocking is not currently the scaling limit
Benchmark fixtures and unit testsThe smallest execution path is usually clearer

For small in-memory graph work, sync is simpler, faster, and easier to debug.

The virtual-thread API makes sense when waiting dominates.

SituationWhy
Neo4j, Memgraph, or AGE over a networkCompatible blocking I/O can release the carrier thread while the virtual thread waits
Spring MVC or existing blocking service codeYou can retain a thread-per-request model without spreading suspend
Many concurrent graph requestsEach waiting task can use a lightweight virtual thread
Java callers that want CompletableFutureAsync 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.

This benchmark also sharpens the graph DB selection guide.

BackendHow to read the execution model
TinkerGraphUsually sync for fixtures, prototypes, and tests
Neo4jMeasure the real driver under target concurrency when network wait is material
MemgraphInclude network wait and target concurrency even when single-call latency is low
Apache AGEMeasure 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.

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.

Comments

Leave a note or reaction with your GitHub account.