265 req/s on All Three Runtimes (Until You Add a Memory Limit)

The Memory Benchmark · Part 15

Part 15 of the Micronaut native image series. Assumes familiarity with reactive Java (Mono/Flux) and container memory limits.

The Draw

Native image, JVM, and virtual threads walk into a benchmark. Same reactive Micronaut service. Same k6 load profile. Same PostgreSQL. Same mock APIs with realistic latency jitter.

RuntimeReq/sErrorsRSS (avg)
Native (unlimited)2641.0%273 MB
JVM (unlimited)2661.0%551 MB
Virtual threads (unlimited)2651.0%543 MB

265 req/s. All three. The error rates match the mock server’s configured failure rate — the application itself isn’t failing. Reactive code runs identically on native image, HotSpot, and loom-carrier Netty.

If your containers have no memory limits, stop reading. Pick whichever runtime your team already knows.

The Service

This isn’t a hello-world benchmark. Six endpoints exercise every reactive pattern that matters under load:

// /api/survey?grid=40: fan-out 40 weather calls bounded to 8 concurrent
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);

/api/survey?grid=40 fires 40 weather API calls at concurrency 8, plus ISS + NEO + APOD in parallel via Mono.zip. Up to 43 concurrent I/O operations per request. /api/scan does scatter-gather across 4 APIs then writes to PostgreSQL. /api/correlate/recent chains DB reads → API calls → DB writes in a reactive graph with nested flatMap at concurrency 2.

The mock server — 400 lines of Go — adds ISS latency at 10-50ms (3% failure), NEO at 30-150ms (1% failure). Failures split three ways: slow timeout (2s then 504), immediate 500, and connection reset via Hijack() + conn.Close(). Latency is uniform-random, not fixed.

k6 ramps 0 → 200 virtual users over 8.5 minutes, then 500 req/s constant arrival for 2 minutes. Seven endpoints, weighted by cost.

Add a Memory Limit

RuntimeReq/sErrorsRSS (avg)RSS (max)Non-heap
Native 256 MB2590.96%96 MB133 MB0
JVM 256 MB12955.6%244 MB252 MB102 MB
Vthreads 256 MB12823.1%244 MB256 MB102 MB

The draw is over.

Native at 256 MB: 259 req/s, 0.96% error rate — unchanged from unlimited. 96 MB average RSS. The container has 160 MB of headroom it never touches.

JVM at 256 MB: 129 req/s, 55.6% errors. Not slow — dead. The container OOM-kills, Docker restarts it (restart: on-failure), it boots, serves requests for a few seconds, OOM-kills again. 129 req/s is what survives the crash-loop. The logs are java.lang.OutOfMemoryError: Java heap space on repeat, two seconds apart.

In Kubernetes, this is CrashLoopBackOff.

The 100 MB Floor

Non-heap memory — metaspace, code cache, thread stacks — consumes 102 MB before a single request arrives. The JVM config already cuts everything it can:

-Xms64m -Xmx64m -Xss256k -XX:+UseSerialGC -XX:ReservedCodeCacheSize=32m
-Dmicronaut.server.netty.worker.threads=4
-Dmicronaut.netty.event-loops.default.num-threads=4

64 MB heap in a 256 MB container. Under load, 2,281 GC pauses totaling 16 seconds, averaging 7ms each. Buying time until the OOM killer arrives.

Native image: zero non-heap overhead. 256 MB container = 256 MB available heap.

What About 128 MB?

JVM can’t start at 128 MB. Non-heap alone exceeds it.

Req/sErrorsRSS (avg)Avg latencyR2DBC pending
Native 128 MB1773.3%75 MB553 ms1,462

Responses slow down. Requests queue behind the R2DBC connection pool (1,462 pending vs 18 at 256 MB). But the container stays up. It degrades; it doesn’t crash-loop.

Native at half the memory (128 MB, 177 req/s) outperforms JVM at double (256 MB, 129 req/s). 37% more throughput. 3% errors vs 56%.

Virtual Threads Don’t Help

Virtual threads on a fully reactive stack — Netty event loop → Project Reactor → R2DBC — have nothing to unblock. The entire request path is non-blocking already. Loom makes blocking code behave like non-blocking code. This code doesn’t need the favor.

265 req/s unlimited (same as regular JVM). 128 req/s at 256 MB (same). Virtual threads added --add-opens=java.base/java.lang=ALL-UNNAMED and a ClassCastException workaround (-Dmicronaut.metrics.binders.executor.enabled=false) for zero throughput benefit.

The GC story is worse. At 256 MB:

GC pausesAvg pauseTotal GC timePeak CPU
JVM2,2817 ms16.4 s4.9%
Virtual threads45852 ms24.2 s100%

Fewer pauses, each one 7x longer. The GC had to reclaim more per collection. Total pause time went up, not down. Peak CPU hit 100% — the only scenario that saturated the processor.

The Pool Sizing Trap

The most useful discovery wasn’t a runtime comparison. It was a configuration bug.

Early runs showed native-256mb at 55 req/s. Worse than 128 MB. Prometheus told the story: R2DBC pool pending was 4,515 at 256 MB vs 1,073 at 128 MB. The docker-profile default was pool=8.

More memory → bigger Netty buffers per connection → connections held longer → 200 concurrent users fighting over 8 database connections. Classic backpressure bottleneck, invisible until you check the pool metrics.

# application-docker.yml
r2dbc:
  pool:
    max-size: 14    # was 8
    initial-size: 2

Pool=14 dropped pending from 4,515 to 18. Throughput went from 55 to 259 req/s. At 128 MB, pool=10 — tighter, but smaller buffers cycle connections faster.

The flatMap concurrency math matters here. The correlation endpoint uses flatMap(..., 2) nested inside flatMap(..., 2) — 4 peak connections. The survey endpoint uses flatMap(..., 8). Mix them under load and you need pool headroom. The pool size isn’t a default you leave alone; it’s a performance lever.

The Greedy Runtime

Native unlimited: 820 MB peak RSS, 592 MB peak heap, same 264 req/s that the 256 MB container delivers. GC never collects aggressively without memory pressure.

JVM unlimited: 688 MB peak RSS. Micrometer reports 230 MB heap + 111 MB non-heap = 341 MB. The other 347 MB — thread stacks, mmap’d libraries, JVM internals — exists in no dashboard. Cloud billing uses RSS.

Both runtimes, unconstrained, take everything offered. The difference is what they need.

The Table

ScenarioReq/sErrorsRSS avgPoolPending
native-unlimited2641.0%273 MB200
native-256mb2590.96%96 MB1418
native-128mb1773.3%75 MB101,462
jvm-unlimited2661.0%551 MB200
jvm-256mb12955.6%244 MB81,894
vthreads-unlimited2651.0%543 MB200
vthreads-256mb12823.1%244 MB82,219

Native at 256 MB delivers unlimited-tier throughput at a third of the RSS. Native at 128 MB delivers degraded-but-alive where JVM delivers a crash-loop. Virtual threads on a reactive stack are a configuration tax with no throughput payoff.

The production question was never “which runtime is fastest.” It’s which one stays up when the memory limit hits.

Sources