Surefold
On this page

Benchmark study

Blocking vs reactive

What changes when you compare virtual threads, asynchronous windows and reactive streams on the same workload? The results make the case for bounded work, and show why the I/O client matters.

Compared
JDK virtual threads, Lark, Reactor, Pekko
Environment
One four-core cloud VM
Evidence
Published benchmark results

What are we choosing?

Virtual threads let a caller wait without occupying a platform thread for the whole wait. Reactive streams compose asynchronous work and use demand to control how much enters a pipeline. Both still need a bound on work in flight: a dependency can run out of capacity long before the application runs out of threads.

This experiment compares plain JDK virtual threads, Lark streams, Project Reactor and Pekko Streams. It asks how those models behave as concurrency grows, where the CPU and memory go, and what happens when a dependency loses capacity. The author prefers blocking code and published testable predictions before measuring; the results include cases that favour Reactor.

Lark is the current repository name for the library presented here as Surefold Core. These measurements use unreleased Lark source. They do not describe the performance of the published 0.10.0 release.

Blocking bodies and asynchronous bodies

The shared-device test gives each item one simulated 10 ms I/O call, bounds concurrency, then sums the answers. Every contestant uses the same client: a future completed by a two-thread timer. What differs is how the pipeline waits.

How the shared-device contestants execute a call
ModelCall bodyControl
Plain virtual threadsJoin the future on a virtual threadSemaphore before submission
Lark mapParJoin the future on a virtual threadBounded parallel window
Lark mapAsyncCompose the CompletionStageBounded asynchronous window
Reactor flatMapCompose Mono.fromFutureExplicit concurrency
Pekko mapAsyncUnorderedCompose the CompletionStageExplicit parallelism

Lark’s strongest high-concurrency result is for mapAsync, which parks no thread per call. It demonstrates an asynchronous option within the toolkit. The blocking mapPar result needs to be read separately. Lark also has unordered variants; the implementation shows every contestant.

A second family of tests changes the waiting mechanism: virtual threads sleep, Reactor uses Mono.delay, Pekko uses its scheduler, and Lark’s asynchronous body uses a JDK timer. Those rows compare scheduler choices as well as stream implementations.

Bounds first, then throughput

In the 1 ms backpressure scenario with a limit of 16, every bounded contestant kept 16–17 items outstanding. The extra item can be pulled before admission. The unbounded virtual-thread version reached 18,289. Cheap threads still need admission control.

With 1,000 calls in flight and a 10 ms delay, the shared-device contestants achieved roughly 93,000–96,000 operations/s, near the ideal ceiling of 100,000. Throughput alone offers little separation there.

Shared device, target 10,000 calls in flight · recorded medians
ModelOperations/s ↑CPU µs/call ↓Live heap B/pending call ↓
Plain virtual threads212,43214.281,236
Lark mapPar249,59211.64720
Lark mapAsync671,9293.49200
Reactor flatMap433,9363.06322
Pekko mapAsyncUnordered359,9364.90282

At 10,000, Lark’s asynchronous window processes about 1.55 times Reactor’s throughput, while using slightly more CPU per call. Its blocking window trails Reactor. With each model’s own timer, the order changes: plain virtual threads reach 828,816 operations/s, Lark mapPar 770,764 and Reactor 269,256. The waiting mechanism materially changes the result.

At a target of 100,000 on the shared device, Lark mapAsync reaches 451,544 operations/s, Reactor 379,386 and Pekko 377,962. Reactor never reaches the harness’s 90% threshold for a live-heap sample in that scenario, so there is no measured per-call heap value to compare for it. The recorded summary contains all variants, including the extreme own-timer case.

The HTTP client changes the picture

The network scenario sends HTTP/1.1 GET requests over loopback with keep-alive and a 10 ms server delay. A separate Netty server JVM uses one pinned core; clients use the other three.

At 1,000 calls in flight, the handwritten blocking Socket client uses 28.45 µs of client CPU per call, Lark mapPar with that client uses 26.50 µs, and Reactor Netty uses 41.65 µs. The socket clients reach about 69,000 operations/s as the server approaches its one-core ceiling.

The small socket client implements less HTTP machinery than Reactor Netty’s codec, so its margin cannot be transferred to a production HTTP client. In the same scenario, the JDK HttpClient versions use 118–160 µs per call across the plain virtual-thread, Lark and Reactor implementations. Inspect the network implementations before attributing a client-library result to an entire concurrency model.

When the dependency shrinks

A separate experiment gives a dependency capacity for 500 concurrent calls, each taking 5 ms. Halfway through 200,000 calls, capacity drops to 100. Latency includes time in the dependency’s queue.

Capacity-drop experiment · recorded medians
Controlp99 after drop ↓Total run time ↓
Reactor, fixed 1,00054.4 ms6.23 s
Reactor, fixed 50029.6 ms6.26 s
Lark, fixed 1,00053.8 ms6.21 s
Lark, adaptive limit10.1 ms7.23 s

The adaptive limit starts at 20 and adjusts concurrency as calls complete. It protects tail latency here at the cost of a longer overall run. This compares admission policies as well as libraries: the Reactor implementation uses a fixed bound. An adaptive controller could also be integrated with a reactive pipeline; that alternative was not measured. Read the capacity-drop implementation and the Core source-version guidance before trying the unreleased API.

Where Reactor wins

In the JMH map/filter/map/sum chain, Reactor takes 16.02 ms per million elements against Lark’s 25.61 ms. A plain loop takes 0.90 ms. When there is no waiting to hide, operator and allocation costs matter.

Reactor’s publishOn example takes 26.09 ms, Lark’s buffer 47.85 ms and a plain ArrayBlockingQueue between virtual threads 440.69 ms. The hand-off semantics differ: after its first batch, Reactor’s synchronous range can emit on the consumer thread as demand is replenished, while Lark’s buffer keeps upstream on a separate thread. These numbers measure those pipelines, rather than an equivalent dedicated-producer hand-off. See the hand-off source.

Method and limits

The published environment is one four-core cloud VM, Temurin JDK 25.0.5, Kotlin 2.4.10, Reactor Core 3.8.8, Reactor Netty HTTP 1.3.7 and Pekko Streams 1.2.1. The author advises treating differences below roughly 20% as noise.

The custom I/O and network harnesses use two fresh JVM forks, two warm-up iterations and four measured iterations. The aggregation script takes medians, except maximum outstanding work, where it keeps the largest sample. Outstanding work is sampled every millisecond. Live heap is a separate full-GC measurement once at least 90% of target concurrency is observed; it is distinct from allocation and peak process RSS. The harness defines those measurements.

JMH uses separate settings: two forks, three warm-up iterations and five measured iterations, each two seconds. The overload experiment runs twice per contestant after a warm-up.

Synthetic timers and loopback HTTP leave out inter-machine latency, packet loss and bandwidth limits. The HTTP server can set the throughput ceiling. Neither the Reactor nor Pekko implementation has had a specialist review. These tests also leave out coroutines, Flow and an open-model server comparison at saturation.

For an application decision, first choose and bound the actual client operation. Then measure its latency, CPU and memory under the concurrency and failure conditions you expect. These results give concrete cases to investigate; they do not establish a universal winner.

Source, results and reproduction

This article was checked on 23 October 2026 against benchmark revision 7de18eb. Its links pin the inspected source and recorded results. The suite was not rerun for this write-up.

The reported Lark build comes from unreleased main, published locally. The documentation does not identify its exact commit, which limits reproduction of these figures. The benchmark build defaults to Lark 0.10.0 unless overridden, so a default build is not the reported candidate run. The repository also archives a separate results/lark-0.10.0/ baseline.

Use the pinned run instructions to publish the required Lark modules locally, set LARK_VERSION to that candidate and run the JMH, I/O and network scripts. Record the Lark revision alongside your environment; the full network sweep is documented as taking about three hours. Regenerate the summary from raw output rather than comparing an old table with new code.