Loom Carrier on a Reactive Stack: 78 MB for Free
Contents
“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.
| Scenario | Requests | Avg Latency | RSS avg | RSS max | Threads |
|---|---|---|---|---|---|
| native-128mb | 1.63M | 0.404ms | 57 MB | 66 MB | 80 |
| native-256mb | 1.64M | 0.362ms | 63 MB | 100 MB | 81 |
| native-unlimited | 1.64M | 0.355ms | 98 MB | 156 MB | 80 |
| jvm-256mb | 1.66M | 0.235ms | 242 MB | 247 MB | 74 |
| jvm-unlimited | 1.66M | 0.246ms | 706 MB | 724 MB | 98 |
| jvm-vthreads-256mb | 1.66M | 0.245ms | 248 MB | 254 MB | 84 |
| jvm-vthreads-unlimited | 1.66M | 0.227ms | 628 MB | 646 MB | 101 |
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 pauses | Total time | Avg pause | |
|---|---|---|---|
| jvm-256mb | 8,349 | 8.78s | 1.051ms |
| jvm-vthreads-256mb | 8,497 | 9.65s | 1.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
- Micronaut Loom Carrier Blog Post — official writeup on the feature, introduced in 4.9.0
- LoomCarrierConfiguration API — configuration properties reference
- JEP 444: Virtual Threads — the JEP that made virtual threads permanent in Java 21
- Micronaut Project Loom Issue #7724 — original issue tracking Loom support
- Reactor
Schedulers.javasource —DEFAULT_BOUNDED_ELASTIC_ON_VIRTUAL_THREADSproperty andboundedElastic()implementation