Loom Carrier on a Reactive Stack: 78 MB for Free

The Memory Benchmark · Part 11

“Virtual threads don’t help reactive apps.” You’ve heard this. Probably said it. The reasoning is sound: a fully reactive stack — Mono/Flux from HTTP client through R2DBC to Netty — never blocks a platform thread. Virtual threads solve blocking. No blocking, no benefit. QED.

We ran the benchmark anyway. The conventional wisdom is correct about throughput and wrong about everything else.

The Stack

Space Observatory: Micronaut 5.0.0, Netty, R2DBC PostgreSQL, Reactor Core, Micrometer. Reactive end-to-end — no subscribeOn(Schedulers.boundedElastic()), no blocking JDBC, no thread-per-request anywhere. GraalVM CE 25, Java 25.

The Feature

Micronaut 4.9 introduced an experimental loom-carrier mode (carried into 5.0.0, which is what we’re testing). Instead of running Netty event loops on platform threads, each event loop thread becomes a virtual thread carrier. Request processing still flows through Reactor operators — the event loop just happens to be virtual.

Two system properties:

-Dmicronaut.server.netty.worker.loom-carrier=true
-Dmicronaut.netty.event-loops.default.loom-carrier=true

The first handles server-side event loops. The second handles HTTP client event loops (our NASA/ISS/weather API calls). Both needed — otherwise the client side stays on platform threads while the server side goes virtual.

Setup Friction

Bug 1: InaccessibleObjectException

LoomCarrierGroup accesses VirtualThread.DEFAULT_SCHEDULER via reflection. On Java 25, java.lang is closed by default. Without --add-opens:

java.lang.reflect.InaccessibleObjectException:
  Unable to make field static final java.util.concurrent.ForkJoinPool
  java.lang.VirtualThread.DEFAULT_SCHEDULER accessible

Application starts, accepts connections, processes nothing.

Fix: --add-opens=java.base/java.lang=ALL-UNNAMED

Bug 2: ClassCastException from Micrometer

Micronaut’s ExecutorServiceMetricsBinder wraps every executor in a metrics proxy. LoomCarrierGroup is an executor. The proxy doesn’t implement EventLoopGroup. Netty casts the event loop group. ClassCastException.

The metrics framework can’t handle the thing it’s trying to measure.

Fix: -Dmicronaut.metrics.binders.executor.enabled=false. Executor-level metrics disappear. JVM metrics, HTTP metrics, R2DBC pool metrics — all still report. Everything we actually watch in Prometheus survives.

Bug 3 (the non-bug)

Research turned up -Dreactor.schedulers.defaultBoundedElasticOnVirtualThreads=true, a Reactor Core 3.6.0 property. Should we add it?

No. Schedulers.boundedElastic() handles blocking calls. Our stack has none. R2DBC drives Postgres through Netty channels. The loom-carrier on the event loop IS where request processing happens. Adding this flag would configure a scheduler we never invoke.

The Hypothesis

Our baseline JVM-256MB scenario survives full load with aggressive thread limits: 4 Netty worker threads + 4 event loop threads + 2 scheduled threads. Every platform thread costs a 256 KB stack. At 74 threads max, that’s ~18 MB just in stacks.

With virtual threads as carriers, those stacks live on the heap and grow lazily — a few hundred bytes until they actually need more. We doubled the thread counts (8+8) to test whether the memory budget would notice.

The Data

Seven scenarios. ~1.6 million requests each. Zero errors across all scenarios. k6 ramping from 10 to 200 virtual users plus 500 req/s constant arrival rate.

ScenarioRequestsAvg LatencyRSS avgRSS maxThreads
native-128mb1.63M0.404ms57 MB66 MB80
native-256mb1.64M0.362ms63 MB100 MB81
native-unlimited1.64M0.355ms98 MB156 MB80
jvm-256mb1.66M0.235ms242 MB247 MB74
jvm-unlimited1.66M0.246ms706 MB724 MB98
jvm-vthreads-256mb1.66M0.245ms248 MB254 MB84
jvm-vthreads-unlimited1.66M0.227ms628 MB646 MB101

What the Numbers Say

Throughput: identical. All JVM scenarios serve ~1.66 million requests. Conventional wisdom confirmed — virtual threads don’t unlock more throughput for a non-blocking stack.

Latency: marginally better. jvm-vthreads-unlimited at 0.227ms is the best of all seven scenarios, beating jvm-unlimited at 0.246ms. At 256 MB, effectively identical (0.245 vs 0.235ms). Not a reason to enable the feature on its own.

RSS unlimited: 78 MB less. Here’s the finding. 628 MB vs 706 MB. Same heap limits, same throughput, same functionality. 78 fewer megabytes of resident memory.

Virtual thread carriers multiplex work onto fewer OS-level thread stacks. The JVM creates fewer platform threads when carrier threads absorb the scheduling. Three more logical threads (101 vs 98), 78 MB less RSS. At cloud pricing where you pay per-MB-hour, this is free money.

RSS constrained: marginal. 248 vs 242 MB. Six megabytes. But: the vthreads scenario runs 8+8 threads instead of 4+4. Ten more threads for six megabytes of RSS. Virtual thread stacks are nearly free.

GC: slightly more work.

GC pausesTotal timeAvg pause
jvm-256mb8,3498.78s1.051ms
jvm-vthreads-256mb8,4979.65s1.136ms

148 extra pauses, 0.87 seconds more total. Virtual thread continuation frames live on the heap — Serial GC has slightly more to collect. No visible impact on request latency.

The JVM Flags

For 256 MB with virtual threads:

-Xms64m -Xmx64m -Xss256k
-XX:+UseSerialGC -XX:ReservedCodeCacheSize=32m
--add-opens=java.base/java.lang=ALL-UNNAMED
-Dmicronaut.server.netty.worker.loom-carrier=true
-Dmicronaut.netty.event-loops.default.loom-carrier=true
-Dmicronaut.metrics.binders.executor.enabled=false
-Dmicronaut.server.netty.worker.threads=8
-Dmicronaut.netty.event-loops.default.num-threads=8
-Dmicronaut.executors.scheduled.core-pool-size=2

For unlimited, drop the memory tuning — keep the --add-opens, loom-carrier flags, and metrics disable.

When to Enable This

Unlimited memory (cloud default): Yes. 78 MB savings, no throughput penalty, best latency of any scenario. The cost is an experimental flag and a --add-opens that nobody loves.

Memory-constrained (256 MB): Marginal. Six megabytes won’t change your cost tier. The doubled thread count is nice but doesn’t move latency. If you’re already fighting at the boundary with Serial GC and thread limits, an experimental flag isn’t the highest-leverage change.

Native image: Not applicable. 57 MB avg RSS. This entire discussion is a JVM concern.

The Real Lesson

“Virtual threads don’t help reactive apps” is a throughput statement dressed as a universal truth. Throughput unchanged? Yes. Memory usage reduced? By 11%, at unlimited, for free.

The conventional wisdom answers the question everyone asks and ignores the one that shows up on your cloud bill.

Sources