Native Image Grew to 734 MB
Contents
Part 20 of the Micronaut native image series. Field notes from running benchmark scenarios across 18 branches — three frameworks, three runtimes, four memory tiers. No narrative arc. Just five things that surprised me.
1. Native is not automatically small
The pitch: GraalVM native images are lean. Sub-100 MB footprint. Container-friendly. Cloud-native.
The CRUD workload confirms this. Native RSS across all frameworks ranges from 31 to 86 MB. JVM containers running the same code: 351-643 MB. The marketing copy is accurate — for CRUD.
Now run the fan-out workload — upstream API calls, aggregation, writes — with no memory limit. Uncapped native RSS across 18 branches: 410-734 MB. The 734 MB top end is a native image, not a JVM tax. Constrain to 256 MB and the same workload settles into 86-159 MB — constrained, not lean by nature.
The native compilation model doesn’t magically compress your working set. It eliminates JIT compilation overhead, reflection metadata, and class-loading infrastructure. On a CRUD workload where those dominate, native is genuinely 5-10x smaller than JVM. On a fan-out workload where the working set is dominated by in-flight HTTP responses, connection buffers, and parsed JSON objects, native just removes the JVM tax on top of whatever your application actually needs.
The memory limit is what keeps native lean. Remove it and your “lightweight native image” uses as much RAM as the workload demands.
2. JVM hides a factor of 8
Same CRUD service. Same SQL. Same traffic:
| Runtime | RSS range |
|---|---|
| Native | 31-86 MB |
| JVM | 351-643 MB |
Factor of 8 to 10.
The JVM can’t even start at 128 MB. Non-heap — metaspace, code cache, thread stacks — exceeds 100 MB before any application code loads.
Container RSS is the number to size by. Not -Xmx (that’s just the heap ceiling). Not Runtime.getRuntime().totalMemory() (that’s the JVM’s view of its own heap, excluding everything else it allocated). Not jcmd NativeMemoryTracking (that misses memory-mapped files and direct buffers). Docker’s container_memory_rss is what the kernel accounts, what the cgroup enforces, and what gets your pod killed.
3. Your config file is lying to you
For three weeks, every Micronaut “pool=20” benchmark run actually ran pool=8.
The setup: benchmark scenarios export R2DBC_POOL_MAX_SIZE=20 for the unlimited tier. The docker-compose file passes it to the container. The application reads it. Done.
Except:
- The Micronaut compose files injected
HIKARI_POOL_MAX_SIZEinstead ofR2DBC_POOL_MAX_SIZE— the wrong variable name, silently ignored application-docker.yml(the Docker profile override) hardcodedmax-size: 8or read the wrong variable, overriding whatever the baseapplication.ymlpicked up- Exit code: 0. No warning. No log line. The app started with pool=8, the scenario script believed it set 20, and three weeks of “unlimited tier” benchmarks measured the wrong pool size
Discovery: comparing the hikaricp_connections_max Prometheus metric across frameworks. Quarkus and Spring Boot showed 20. Micronaut showed 8. The metric doesn’t lie — the config file does.
Fixed on all 4 affected Micronaut branches. Every row in the current report post-dates the fix. But three weeks of runs are in the garbage.
Verify config through metrics. Not through YAML.
4. git ls-tree exits 0 on nothing
$ cd /tmp/some-untracked-subdir
$ git ls-tree origin/main -- src/main/java/dev/tsvinc/space/dao
$
No output. Exit code 0. No error. No warning.
git ls-tree scopes paths relative to the current working directory’s position within the repository. From a subdirectory, src/main/java/... resolves to a path that doesn’t exist at that level of the tree. Git considers “no matching paths” a perfectly valid result, not an error.
Half an hour suspecting repository corruption, re-cloning, checking reflog, before realizing the terminal was cd’d into an untracked scratch directory. The command worked perfectly — it just found nothing, and told nobody.
5. Unit tests found 0 of 7 bugs
All 18 synth branches passed ./mvnw test. Every port. Every framework. Green across the board.
Then the load tests ran — k6 at 500 req/s against real Postgres, real connection pools, real concurrency — and found seven bugs:
- R2DBC converter — a type mapping that worked in single-threaded tests but produced wrong types under concurrent access
- Hibernate session concurrency — Panache shared a session across reactive operators that ran on different threads
- Rest-client pool starvation — Spring Boot’s shared HTTP client pool capped fan-out until split per client
- Concurrent-INSERT deadlock — two requests inserting the same unique key, one getting a constraint violation, neither releasing their connection in the right order
- Nested connection borrow — a
COUNTquery opening a second connection inside a DAO method that already held one, deadlocking at pool=8 - Reactor/Mutiny concurrency mismatch —
zipWithrunning page+count in parallel on Reactor but sequentially on Mutiny - Partial-commit path — transaction blocks catching
SQLExceptionbut notThrowable, letting runtime exceptions slip past rollback into autocommit territory
Unit tests run one request at a time against an embedded database with no connection pool. They test SQL correctness. They don’t test what happens when eight connections are all borrowed and the ninth request arrives. They don’t test what happens when two threads hit the same unique constraint simultaneously. They don’t test what happens when your transaction cleanup catches the wrong exception hierarchy.
The benchmark is also a correctness test. The unit tests are necessary but not sufficient.
Sources
- Benchmark data across 18 branches (three frameworks, three runtimes, four memory tiers) — internal, unpublished