We Removed Reactor. Nothing Got Slower.

The Memory Benchmark · Part 12

We took the entire reactive stack out. R2DBC, Mono, Flux, Reactor Core, loom-carrier, flatMap with its carefully-tuned concurrency arguments. Replaced it with JDBC, HikariCP, and blocking method calls. Same endpoints. Same Netty HTTP server. Same PostgreSQL. Same k6 load profile: 10 to 200 VUs, 500 req/s constant arrival rate, seven scenarios each.

Throughput didn’t move. Latency dropped. Memory dropped. GC pauses dropped 63% at 256 MB.

The Scoreboard

This is part of the series benchmarking a Micronaut 5.0.0 app under memory constraints. The reactive version is the one from Part 10 and Part 11 — fully tuned, flatMap concurrency bounded, R2DBC pool sized to match, loom-carrier enabled for virtual threads. The imperative version: JDBC/HikariCP, THREAD_PER_TASK executor for virtual threads, same pool size.

Memory (Container RSS, max during benchmark)

ScenarioReactiveImperativeDelta
native-128mb66 MB59 MB-11%
native-256mb100 MB75 MB-25%
native-unlimited156 MB92 MB-41%
jvm-256mb247 MB238 MB-4%
jvm-unlimited724 MB555 MB-23%
jvm-vthreads-256mb254 MB247 MB-3%
jvm-vthreads-unlimited646 MB465 MB-28%

Every row. Every scenario. Less memory.

Native-unlimited went from 156 MB to 92 MB. That’s the R2DBC driver’s memory-mapped buffers, Reactor’s operator class metaspace, and extra thread stacks — gone.

Latency (ms)

ScenarioReactive avgImperative avgReactive p95Imperative p95
native-128mb0.960.552.711.20
native-256mb0.820.682.141.86
native-unlimited0.810.482.080.85
jvm-256mb0.470.261.080.44
jvm-unlimited0.480.280.990.46
jvm-vthreads-256mb0.510.391.250.97
jvm-vthreads-unlimited0.470.340.900.65

JVM at 256 MB: average latency dropped 45%. p95 dropped 59%. The imperative code is doing less work per request — fewer operator allocations, no scheduler context switches, no reactive chain assembly.

Throughput (req/s)

ScenarioReactiveImperativeDelta
native-128mb24542448-0.3%
native-256mb24702456-0.5%
native-unlimited24682462-0.2%
jvm-256mb24992491-0.3%
jvm-unlimited24972490-0.3%
jvm-vthreads-256mb24802507+1.1%
jvm-vthreads-unlimited24992515+0.6%

Noise. The throughput bottleneck was always PostgreSQL and external API latency, not the in-process execution model. Virtual threads on imperative are marginally faster — THREAD_PER_TASK is a cleaner abstraction than loom-carrier wrapping Netty event loops.

Where the Savings Come From

Allocation pressure

A Mono.flatMap() allocates a MonoFlatMap.FlatMapMain subscriber. A Flux.flatMap() allocates FluxFlatMap.FlatMapMain. Every map(), every filter(), every switchIfEmpty(). A typical endpoint in this app chains 8-12 operators. At 500 req/s, that’s 4,000-6,000 short-lived objects per second that exist only to pass a value downstream.

The imperative version: a method call returns a value. The JVM inlines it.

At 256 MB with Serial GC, this difference is 8,349 GC pauses vs 3,042. Eight seconds of total GC time vs three.

Thread stacks

Reactive: 74-101 threads across JVM scenarios. Imperative: 49-72. Fewer Reactor scheduler threads (none needed), fewer R2DBC event loop threads (JDBC uses the calling thread). Each thread stack is 1 MB by default — that’s 20-30 MB of RSS in stack space alone.

Non-heap

ScenarioReactiveImperative
jvm-256mb103 MB95 MB
jvm-unlimited120 MB101 MB
jvm-vthreads-256mb103 MB95 MB
jvm-vthreads-unlimited122 MB110 MB

8-20 MB of metaspace gone. No Reactor classes loaded. No R2DBC driver. No reactive operator bytecode.

Connection pool behavior

The reactive version at native-unlimited had 93 pending connections in the R2DBC pool. Ninety-three requests waiting for a connection. The imperative version at the same scenario: zero pending.

R2DBC’s pool is cooperative — it relies on reactive backpressure signals to manage contention. Under high concurrency, this cooperation breaks down. HikariCP blocks the calling thread until a connection is available. Simpler. More predictable. Zero pending on every JVM scenario.

What We Lost

Readability. Wait — we gained that too.

The reactive version of fetchAndStoreObservations:

return nasaClient.getApod(apiKey)
    .flatMap(apod -> observationRepository.save(toEntity(apod)))
    .flatMap(saved -> weatherClient.getForecast(lat, lon)
        .flatMap(weather -> observatoryService.findNearest(lat, lon)
            .flatMap(obs -> enrichWithWeather(saved, weather, obs))))
    .switchIfEmpty(Mono.error(new ServiceException("No APOD")));

The imperative version:

var apod = nasaClient.getApod(apiKey);
var saved = observationRepository.save(toEntity(apod));
var weather = weatherClient.getForecast(lat, lon);
var observatory = observatoryService.findNearest(lat, lon);
return enrichWithWeather(saved, weather, observatory);

Same behavior. Stack traces that make sense. Debugger breakpoints that stop where you expect. No checkpoint() operator to figure out which flatMap threw.

When Reactive IS the Right Call

This workload is CRUD plus three external API calls per request. The reactive model shines somewhere else entirely:

Fan-out parallelism — one inbound request triggers 50+ concurrent downstream calls. Reactive’s Flux.merge() with bounded concurrency is genuinely elegant here. A virtual-thread-per-call approach works too, but reactive gives you backpressure for free.

Long-lived idle connections — WebSocket or SSE with thousands of connected clients holding open connections. Platform threads per connection doesn’t scale. Virtual threads would, but reactive was here first and the ecosystem (Netty handlers, Reactor Kafka consumers) is mature.

Streaming with backpressure — Kafka consumer → transform → database writer where the writer is slower than the consumer. Reactor’s limitRate() and onBackpressureBuffer() are purpose-built. Imperative code needs manual queue management.

This app has none of that. Most microservices don’t.

The Decision

Micronaut’s default project generator gives you R2DBC and reactive repositories. The framework’s documentation leads with reactive examples. Every tutorial shows Mono<T> return types. The implicit message: reactive is the modern approach.

For a service that handles requests one at a time, queries a database, calls an API, and returns JSON — reactive is overhead. Measurable overhead. 41% more memory in native. 63% more GC pauses at 256 MB. Higher p95 latency under identical load.

The imperative rewrite took five days. The benchmark results took eleven minutes per scenario. The conclusion took one table.

Sources