265 req/s on All Three Runtimes (Until You Add a Memory Limit)
Contents
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.
| Runtime | Req/s | Errors | RSS (avg) |
|---|---|---|---|
| Native (unlimited) | 264 | 1.0% | 273 MB |
| JVM (unlimited) | 266 | 1.0% | 551 MB |
| Virtual threads (unlimited) | 265 | 1.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
| Runtime | Req/s | Errors | RSS (avg) | RSS (max) | Non-heap |
|---|---|---|---|---|---|
| Native 256 MB | 259 | 0.96% | 96 MB | 133 MB | 0 |
| JVM 256 MB | 129 | 55.6% | 244 MB | 252 MB | 102 MB |
| Vthreads 256 MB | 128 | 23.1% | 244 MB | 256 MB | 102 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/s | Errors | RSS (avg) | Avg latency | R2DBC pending | |
|---|---|---|---|---|---|
| Native 128 MB | 177 | 3.3% | 75 MB | 553 ms | 1,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 pauses | Avg pause | Total GC time | Peak CPU | |
|---|---|---|---|---|
| JVM | 2,281 | 7 ms | 16.4 s | 4.9% |
| Virtual threads | 458 | 52 ms | 24.2 s | 100% |
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
| Scenario | Req/s | Errors | RSS avg | Pool | Pending |
|---|---|---|---|---|---|
| native-unlimited | 264 | 1.0% | 273 MB | 20 | 0 |
| native-256mb | 259 | 0.96% | 96 MB | 14 | 18 |
| native-128mb | 177 | 3.3% | 75 MB | 10 | 1,462 |
| jvm-unlimited | 266 | 1.0% | 551 MB | 20 | 0 |
| jvm-256mb | 129 | 55.6% | 244 MB | 8 | 1,894 |
| vthreads-unlimited | 265 | 1.0% | 543 MB | 20 | 0 |
| vthreads-256mb | 128 | 23.1% | 244 MB | 8 | 2,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
- Micronaut Framework Documentation — server configuration, R2DBC pool tuning
- Reactor Flux Javadoc — flatMap concurrency parameter
- GraalVM Native Image — memory model, SerialGC behavior
- k6 Documentation — ramping-vus, constant-arrival-rate executors
- Netty Issue #15762 — Arena.ofShared SharedArenaSupport flag
- Full benchmark results — k6 summaries, prometheus CSVs, analysis