Designed a Reactive Benchmark. Imperative Won.
Contents
Part 16 of the Micronaut native image series. Follows Part 15, which benchmarked the reactive service alone. Assumes familiarity with Project Reactor and virtual threads.
The Hypothesis
The workload was designed to make reactive shine. Six endpoints exercising parallel fan-out, scatter-gather, bounded concurrency, and backpressure. A Go mock server injecting realistic latency jitter — ISS at 10-50ms with 3% failures, NEO at 30-150ms, connection resets via Hijack() + conn.Close(). k6 ramping to 200 VUs then sustaining 500 req/s constant arrival. If reactive Micronaut had a home-court advantage anywhere, it was here.
Then I rewrote the service in imperative style — JDBC, HikariCP, virtual threads, Semaphore — and ran the same benchmark.
The Table
Reactive (R2DBC, Mono/Flux):
| Scenario | Req/s | Errors | RSS avg | GC pauses | Avg pause |
|---|---|---|---|---|---|
| native-unlimited | 264 | 0.0% | 273 MB | 0 | — |
| native-256mb | 259 | 0.0% | 96 MB | 0 | — |
| native-128mb | 177 | 2.3% | 75 MB | 0 | — |
| jvm-unlimited | 266 | 0.0% | 551 MB | 2,012 | 1.1 ms |
| jvm-256mb | 129 | 55.1% | 244 MB | 2,281 | 7.2 ms |
| vthreads-unlimited | 265 | 0.0% | 543 MB | 2,051 | 1.2 ms |
| vthreads-256mb | 128 | 22.3% | 244 MB | 458 | 52.8 ms |
Imperative (JDBC, HikariCP, virtual threads):
| Scenario | Req/s | Errors | RSS avg | GC pauses | Avg pause |
|---|---|---|---|---|---|
| native-unlimited | 261 | 0.0% | 204 MB | 0 | — |
| native-256mb | 256 | 0.0% | 89 MB | 0 | — |
| native-128mb | 252 | 0.0% | 69 MB | 0 | — |
| jvm-unlimited | 258 | 0.0% | 483 MB | 1,274 | 1.1 ms |
| jvm-256mb | 232 | 0.0% | 236 MB | 6,085 | 22 ms |
| vthreads-unlimited | 257 | 0.0% | 480 MB | 1,514 | 1.0 ms |
| vthreads-256mb | 248 | 0.0% | 247 MB | 6,274 | 17 ms |
Every imperative scenario: zero errors. Every one.
The Reversals
Unlimited memory — no meaningful difference. Both implementations land at ~260 req/s with zero errors. (Part 15’s reactive run showed ~1% errors at unlimited — mock failures are randomized per run, and both stacks handle them via fallback paths. The variance is the mock, not the application.) If your containers don’t have memory limits, the stack choice doesn’t matter. Pick whichever your team can debug at 2 AM.
At 256 MB, it matters:
| Reactive | Imperative | |
|---|---|---|
| JVM req/s | 129 | 232 |
| JVM errors | 55.1% | 0.0% |
| Vthreads req/s | 128 | 248 |
| Vthreads errors | 22.3% | 0.0% |
Reactive JVM at 256 MB is a crash-loop — OutOfMemoryError on repeat, Docker restarts via on-failure, a few requests survive between deaths. Imperative at the same limit: 232 req/s, clean run, no restarts.
Native 128 MB — the tier where JVM can’t even boot:
| Reactive | Imperative | |
|---|---|---|
| Req/s | 177 | 252 |
| Errors | 2.3% | 0.0% |
| RSS avg | 75 MB | 69 MB |
Reactive degrades — requests queue behind R2DBC, error rate climbs, throughput drops 33%. Imperative holds 252 req/s. It doesn’t notice the constraint.
Same Concurrency, Different Plumbing
Both implementations bound external API calls to 8 concurrent. The reactive version:
Flux.fromIterable(gridPoints)
.flatMap(point -> meteoClient.currentWeather(point[0], point[1], CURRENT_PARAMS)
.map(w -> toGridResult(point, w))
.onErrorResume(e -> Mono.just(failedGridResult(point))), 8);
The imperative version:
Semaphore semaphore = new Semaphore(8);
List<Future<SurveyResponse.GridResult>> futures = new ArrayList<>();
for (double[] point : gridPoints) {
futures.add(executor.submit(() -> {
semaphore.acquire();
try { return fetchWeatherForPoint(point); }
finally { semaphore.release(); }
}));
}
One expression vs a loop. Same bounded concurrency. Same throughput ceiling. The loop version runs in a 128 MB container with zero errors.
Where the Memory Goes
Both stacks run on Netty. The difference is what sits on top. Reactive adds R2DBC (non-blocking database connections), Project Reactor’s operator chain, and per-subscription context propagation. Imperative replaces that with JDBC and HikariCP — blocking connections, no operator chain. The non-heap difference:
| Reactive | Imperative | |
|---|---|---|
| Non-heap | 97–111 MB | 89–100 MB |
~15 MB. In a 256 MB container with 64 MB heap, 15 MB is the margin between running and OOM-killing.
The connection pool tells the rest. Under full load at JVM 256 MB:
| Reactive (R2DBC) | Imperative (HikariCP) | |
|---|---|---|
| Pending connections | 1,894 | 62 |
R2DBC queues 1,894 pending connections. HikariCP queues 62. JDBC connections are simpler objects — no reactive subscription state, no operator fusion chain, no context propagation overhead. They acquire, execute, release. Under memory pressure, simpler wins.
The GC Story
Reactive JVM at 256 MB: 2,281 GC pauses averaging 7.2 ms. Then it dies.
Imperative JVM at 256 MB: 6,085 GC pauses averaging 22 ms. And survives.
More pauses. Longer pauses. But the imperative service allocates less per request — no Subscription objects, no Context chains, no operator fusion intermediaries. The GC reclaims enough each cycle to stay ahead. The reactive service generates more transient objects per request, and at 64 MB heap, the collector loses the race.
The virtual threads comparison makes the mechanism visible:
| Reactive | Imperative | |
|---|---|---|
| GC pauses | 458 | 6,274 |
| Avg pause | 52.8 ms | 17 ms |
| Errors | 22.3% | 0.0% |
Reactive: fewer collections, each one 3x longer, 22% of requests fail. Imperative: fourteen times more collections, each one fast enough, zero failures. Serial GC with a small heap needs frequent short runs. Give it large allocation bursts and infrequent collection opportunities and it falls behind — that’s the reactive pattern under memory pressure. Give it steady, moderate allocation and it hums along — that’s the imperative pattern.
The reactive GC isn’t bad. It’s collecting a harder workload with less headroom.
What This Doesn’t Mean
This isn’t “reactive is always slower.” At unlimited memory, reactive matched imperative. The overhead — Reactor’s operator chain, R2DBC’s connection model, subscription-per-request allocation — is invisible with enough heap. Most production JVM services run at 512 MB or more. The floor is fine.
The finding is narrower: under memory pressure, the reactive stack’s per-request overhead becomes the dominant cost. Virtual threads eliminate the concurrency advantage without the allocation overhead. At 256 MB JVM, that’s the difference between 55% errors and zero.
The Numbers That Matter
The reactive advantage at unlimited memory: 264 vs 261 req/s. Three requests per second.
The imperative advantage at 128 MB native: 252 vs 177 req/s. Seventy-five requests per second.
The imperative advantage at 256 MB JVM: 232 vs 129 req/s. And the 129 includes OutOfMemoryError restarts.
Sources
- Micronaut Framework Documentation — server configuration, R2DBC and JDBC pool tuning
- Project Reactor Reference — flatMap concurrency, backpressure operators
- JEP 444: Virtual Threads — virtual thread executor, platform thread scheduling
- GraalVM Native Image Memory Management — SerialGC behavior, heap sizing
- k6 Documentation — ramping-vus, constant-arrival-rate executors
- HikariCP — connection pool architecture
- Part 15: 265 req/s on All Three Runtimes — reactive-only benchmark results