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 changes: the blocking wait can be much larger than the wrapping cost.

This article is not “Virtual Threads are faster.” It is about when wrapping is wasted work, and when it makes blocking graph calls easier to scale and reason about.

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 benchmark uses the graph-benchmark module and a TinkerGraph fixture.

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 API-model overhead, not production Neo4j latency.

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 overhead. The benchmark note estimates the virtual-thread path adds roughly 6-8µs. If a lookup takes 1.8µs, that is large. If a real graph DB call waits several milliseconds, it is no longer the first cost to worry about.

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 networkBlocking wait no longer pins an expensive platform thread
Spring MVC or existing blocking service codeYou can add concurrency without spreading suspend
Many concurrent graph requestsEach blocking call can wait in its own 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. For multiple operations, compose futures with CompletableFuture.allOf(...), thenCompose, or thenApply so the wait happens at the right boundary.

This benchmark also sharpens the graph DB selection guide.

BackendHow to read the execution model
TinkerGraphUsually sync for fixtures, prototypes, and tests
Neo4jNetwork I/O and ACID transactions make VT useful for service concurrency
MemgraphEven low-latency graph DB calls still include network wait
Apache AGEPostgreSQL JDBC calls are a natural fit for VT wrapping

The benchmark is not a leaderboard. It is a prompt to ask whether backend wait time is meaningfully larger than API wrapping cost.

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 DB calls under concurrency, Virtual Threads can simplify scaling.
  • Benchmark numbers must be read with backend latency and harness shape.
  • 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.