The Throughput Was Always 115

The Memory Benchmark · Part 17

Part 17 of the Micronaut native image series. Follows Part 16, which compared reactive vs imperative on a lighter workload. Assumes familiarity with Project Reactor and virtual threads.

115

That’s the number. Requests per second. For all twelve scenarios.

Reactive R2DBC on native: 115. Imperative JDBC on JVM: 115. Virtual threads with loom-carrier: 114.5. Reactive on JVM with 512 MB memory limit: 115.2. Imperative on JVM unlimited: 115.1.

Twelve benchmark runs. Two codebases. Three runtime configurations. Six memory/container permutations each. 0% failure rate across the board. And the same number, over and over, like a metronome that doesn’t care what’s playing.

The Workload That Was Supposed To Matter

Part 16 used a four-API benchmark and found reactive collapsing under memory pressure while imperative survived. The natural follow-up: make the workload heavier. Twenty-eight mock API clients. Six endpoints exercising parallel fan-out across all of them. A Go mock server injecting 100-600ms latency (averaging 350ms) with 1-2% failure rates — slow timeouts, immediate 500s, connection resets.

The full-survey endpoint calls all 28 APIs in parallel, extracts typed fields from each response, computes risk scores (seismic, space weather, cross-domain correlations), and writes a summary to PostgreSQL. This is not a hello-world benchmark. There is actual data processing between the network calls.

k6 ramps from 10 to 200 virtual users over eight and a half minutes, then switches to 500 req/s constant arrival rate. Prometheus scrapes every two seconds. Docker stats polls RSS alongside.

The Tables

Reactive (Mono/Flux, R2DBC):

ScenarioReq/sMedianp99Fail%RSS avgGC pauses
native-256mb115.0793ms4136ms0.0%121 MB0
native-unlimited114.4794ms4131ms0.0%144 MB0
jvm-512mb115.2786ms4085ms0.0%408 MB1,397
jvm-unlimited115.4786ms4099ms0.0%495 MB989
vthreads-512mb114.5794ms4125ms0.0%335 MB1,419
vthreads-unlimited114.4788ms4125ms0.0%608 MB575

Imperative (JDBC, HikariCP, virtual threads):

ScenarioReq/sMedianp99Fail%RSS avgGC pauses
native-256mb106.3743ms6392ms0.0%95 MB0
native-unlimited106.0737ms6437ms0.0%108 MB0
jvm-512mb114.9784ms4097ms0.0%335 MB1,049
jvm-unlimited115.1781ms4095ms0.0%507 MB639
vthreads-512mb114.7789ms4118ms0.0%340 MB1,036
vthreads-unlimited115.3779ms4096ms0.0%531 MB601

Ten of twelve scenarios: 114-115 req/s, ~790ms median, ~4100ms p99, zero failures. The two outliers are both imperative native — we’ll get to those.

Why Nothing Matters

The mock server averages 350ms per API call. The full-survey endpoint calls 28 APIs in parallel, but even with perfect concurrency the minimum response time is max(all 28 latencies) — roughly 500-600ms at the tail. Add connection overhead, deserialization of 28 typed DTOs, risk score computation, one database insert. The app’s wall-clock time is dominated by waiting.

CPU peaked under 9% in every scenario. Average was 2-4%.

This is a benchmark of the mock server’s sleep timer, not the application’s concurrency model. Reactive fan-out with Mono.zip() and imperative fan-out with executor.submit() both resolve to the same thing: 28 concurrent HTTP requests dispatched through Netty, then waiting. Netty runs the HTTP layer for both stacks. The non-blocking event loop is always there. Reactor adds operator chains and subscription context on top. Virtual threads add… nothing on top. Both approaches hand 28 requests to Netty and tap their feet.

At 200 VUs with 115 req/s throughput, each request occupies a slot for about 1 second. Two hundred slots, 115 completions per second — the system isn’t saturated. There’s no queue pressure, no contention, no resource competition to expose a difference.

The Native Exception

Imperative native: 106 req/s, not 115.

The 8% deficit appears in both native-256mb and native-unlimited, ruling out memory pressure. The p99 tells the real story: 6,392ms vs 4,136ms for reactive native. Something is slower at the tail.

GraalVM native image supports virtual threads, but the story is different at the tail. The imperative code path creates 28 virtual threads per request via Executors.newVirtualThreadPerTaskExecutor(). Each one blocks on a synchronous Micronaut HTTP client call — which means a virtual thread mount, a Netty dispatch, a 350ms wait, an unmount. On HotSpot, the JIT compiler optimizes continuation switching aggressively. SubstrateVM’s ahead-of-time compiled paths don’t have that luxury. The p99 blows up: 6,392ms vs 4,136ms.

Reactive native pays none of this. Mono.zip() dispatches through Netty’s event loop — no per-task thread lifecycle, no continuation switching. The same operator chain that costs heap on JVM runs lean on native where allocation pressure is lower.

The trade: imperative native uses 22% less RSS (95 MB vs 121 MB average). Fewer Reactor operator objects, no R2DBC connection state, simpler stack frames. But 8% less throughput and 54% worse p99.

500 VUs: Still The Same

Cranked the load to 500 virtual users:

StackReq/sMedianp99RSS avg/max
Reactive jvm-512mb1381413ms6558ms358/391 MB
Imperative jvm-512mb1331240ms6700ms347/383 MB
Imperative vthreads-512mb1361300ms6785ms432/466 MB

All zero failures. Throughput differences within 4%. Reactive ekes out 5 more req/s. Imperative has 170ms lower median. Virtual threads use 25% more memory than imperative JVM for approximately nothing.

The concurrency models are interchangeable when the bottleneck is 350ms of network latency. Doubling the load just makes all three wait longer.

What This Bought: A Type Signature

Here’s the reactive fan-out for 28 APIs:

Mono<Tuple8<SolarFlareResponse, CmeResponse, SolarWindResponse,
        RadiationResponse, MagnetosphereResponse, GravitationalWaveResponse,
        SatellitePassResponse, DebrisAlertResponse>> block1 = Mono.zip(
    solarClient.solarFlares().doOnNext(r -> successes.incrementAndGet())
        .onErrorReturn(EMPTY_SOLAR_FLARE),
    solarClient.cme().doOnNext(r -> successes.incrementAndGet())
        .onErrorReturn(EMPTY_CME),
    // ... 6 more
);

return Mono.zip(block1, block2).zipWith(Mono.zip(block3, block4))
    .flatMap(outer -> {
        Tuple2<Tuple8<...>, Tuple8<...>> left = outer.getT1();
        Tuple2<Tuple8<...>, Tuple4<...>> right = outer.getT2();
        SolarFlareResponse flare = left.getT1().getT1();
        CmeResponse cme = left.getT1().getT2();
        // ... 16 more getT() calls
    });

Mono.zip() maxes out at 8 arguments. Twenty-eight APIs require four zip blocks, then zipping the zips, then destructuring Tuple2<Tuple8<...>, Tuple8<...>> to extract individual responses by positional index. left.getT1().getT3() is “solar wind speed.” Good luck with that in a stack trace.

The imperative version:

Future<SolarFlareResponse> flareFuture = submit(solarClient::solarFlares,
        EMPTY_SOLAR_FLARE, successes);
Future<CmeResponse> cmeFuture = submit(solarClient::cme,
        EMPTY_CME, successes);
// ... 26 more, each one line

awaitAll(flareFuture, cmeFuture, windFuture, /* ... */);

SolarFlareResponse flare = get(flareFuture, EMPTY_SOLAR_FLARE);
CmeResponse cme = get(cmeFuture, EMPTY_CME);

Each variable has a name and a type. The debugger shows you values. The stack trace shows you methods. No positional indexing into nested tuples.

The reactive FullSurveyService is 285 lines. The imperative one is 200. Same imports, same constants, same data processing logic — the business code is identical. Eighty-five lines of difference, and every one of them is Tuple nesting that exists solely to satisfy Mono.zip()’s arity limit. The imperative version has a submit() helper, a get() helper, and an awaitAll() that’s five lines long.

The Actual Finding

The hypothesis was that scaling up API fan-out — from 4 APIs to 28, with realistic latency — would reveal a concurrency advantage. It didn’t. Both stacks resolve to the same infrastructure: Netty dispatches HTTP requests, the mock server sleeps for 350ms, Netty receives responses. Everything between those two events — Reactor operators, virtual thread scheduling, subscription context, Future.get() — is rounding error compared to the network wait.

This isn’t what the reactive-vs-imperative debate looks like in benchmarks. It’s what I/O-bound microservices look like in production. The API call takes 350ms. Your framework’s concurrency model takes microseconds. The ratio is a thousand to one. You could write the fan-out with callbacks, coroutines, green threads, or carrier pigeons. The mock server doesn’t care. The load balancer doesn’t care. The user staring at a loading spinner definitely doesn’t care.

The only scenario where the stack choice registered was native image, where SubstrateVM’s virtual thread overhead costs imperative 8% throughput — and saves 22% memory. A trade-off, not a verdict.

For everything else: pick the one you can read.

Sources