Micronaut 5 Native Image: 14 Entries vs 1,913
Contents
Micronaut 5.0.0 dropped on May 20, 2026. The launch blog post promises first-class GraalVM native image support. The documentation is correct. It is also incomplete in ways that cost hours.
This is what building a non-trivial native image app looks like on day one: 4 HTTP clients hitting real APIs, reactive R2DBC pipeline into PostgreSQL, Liquibase migrations, @Scheduled cron jobs, Swagger UI. Everything that should Just Work and mostly does — except when it doesn’t, and the failure modes are silent.
The App
Space Observatory. Talks to NASA (APOD + Near Earth Object feeds), the ISS position API, and Open-Meteo for weather enrichment. Collects data, persists it in a 7-table PostgreSQL schema, serves it back through REST endpoints.
observation_session (root)
+-- apod_entry (1:N, cascade)
+-- neo_asteroid_record (1:N, cascade)
| +-- neo_close_approach (1:N, cascade)
+-- iss_position (1:N, cascade)
+-- weather_observation (1:1, cascade)
Stack: Java 25, GraalVM CE 25.0.2, Micronaut 5.0.0, R2DBC, Micronaut Serde, Liquibase YAML, Netty.
Declarative Clients Are Beautiful
This is the part that works exactly as advertised. Four @Client interfaces, zero boilerplate:
@Client("nasa-apod")
@Retryable(attempts = "3", delay = "500ms", multiplier = "2.0")
public interface NasaApodClient {
@Get("/planetary/apod")
Mono<ApodResponse> fetchByDate(
@QueryValue("api_key") String apiKey,
@QueryValue String date
);
@Get("/planetary/apod")
Flux<ApodResponse> fetchRange(
@QueryValue("api_key") String apiKey,
@QueryValue("start_date") String startDate,
@QueryValue("end_date") String endDate
);
}
Service URLs live in application.yml, named by the @Client value:
micronaut:
http:
services:
nasa-apod:
url: https://api.nasa.gov
read-timeout: 10s
connect-timeout: 5s
iss:
url: http://api.open-notify.org
read-timeout: 5s
connect-timeout: 3s
At compile time, Micronaut generates the HTTP client implementation. No reflection. No runtime proxies. The ISS endpoint returns the station’s lat/lon, which feeds into Open-Meteo for weather at that position. The whole enrichment chain is reactive:
return issTrackingService.fetchCurrentPosition()
.flatMap(issResponse -> {
IssResponse.IssPositionData pos = issResponse.iss_position();
double lat = Double.parseDouble(pos.latitude());
double lon = Double.parseDouble(pos.longitude());
return weatherService.fetchWeatherAt(lat, lon)
.map(meteo -> new IssCurrentResponse(
lat, lon, issResponse.timestamp(), meteo.current()))
.defaultIfEmpty(new IssCurrentResponse(
lat, lon, issResponse.timestamp(), null));
});
ISS over the Pacific, weather station says 18.7C, wind 12 km/h, 73% clouds. Real data, real APIs, all running in a 131 MB binary.
The DTOs: Records + @Serdeable
Micronaut Serde generates serializers at compile time. No Jackson reflection. The DTOs are plain Java records:
@Serdeable
public record NeoFeedResponse(
NeoLinks links,
int element_count,
Map<String, List<NeoObject>> near_earth_objects
) {
@Serdeable
public record NeoObject(
String id,
String neo_reference_id,
String name,
String nasa_jpl_url,
double absolute_magnitude_h,
EstimatedDiameter estimated_diameter,
boolean is_potentially_hazardous_asteroid,
List<CloseApproachData> close_approach_data,
boolean is_sentry_object
) {}
// ... nested records for velocity, distance, diameter
}
NASA returns snake_case JSON. Micronaut Serde handles field-name-to-record-component mapping at compile time — no @JsonProperty annotations when the record component names match the JSON keys. This is what “compile-time” means in practice: the framework has already done the work before native-image runs.
Liquibase: The First Gotcha
Liquibase uses reflection. A lot of reflection. Every change type — CreateTable, AddForeignKeyConstraint, CreateIndex, Insert — gets resolved at runtime via Class.forName(). In JVM mode, nobody notices. In native image, nothing is available unless you declare it.
The Micronaut docs say to add reflection metadata. They don’t say how much.
I started with 14 hand-crafted entries. CreateTableChange, AddColumnChange, the obvious ones. The binary built. Liquibase started. Then:
liquibase.exception.ChangeLogParseException:
Unknown change type 'addForeignKeyConstraint'
Added AddForeignKeyConstraintChange. Rebuilt (~90 seconds). Another type missing. Rebuilt. Another.
Each iteration is a 90-second native image build to discover one missing class. This loop has a name: bad.
The fix: the GraalVM tracing agent. Run your tests under -agentlib:native-image-agent and it records every reflective access:
./mvnw test -Pnative-agent
The Maven profile attaches the agent to Surefire:
<profile>
<id>native-agent</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-agentlib:native-image-agent=config-output-dir=
${project.basedir}/src/main/resources/META-INF/native-image/
dev.tsvinc/micronaut-native-as-hell</argLine>
</configuration>
</plugin>
</plugins>
</build>
</profile>
The agent discovered 1,913 reflection entries. Liquibase alone: dozens of change types, each with constructors, getters, setters. R2DBC PostgreSQL driver: its own pile. Netty channel handlers. Logback. Micronaut Serde’s runtime fallbacks.
14 hand-crafted entries vs. 1,913 agent-discovered. That’s the gap between “first-class native image support” and reality.
Arena.ofShared: The Second Gotcha
The native binary builds. Starts in under a second. Serves requests. Smoke test passes. Ship it.
Then run 50 concurrent connections:
java.lang.UnsupportedOperationException:
SharedArena is not supported in this configuration
Netty on GraalVM 25 uses java.lang.foreign.Arena.ofShared() for buffer management. GraalVM Community Edition native images don’t support shared arenas by default. This is netty/netty#15762.
The fix is a flag:
Args = -H:+SharedArenaSupport --enable-native-access=ALL-UNNAMED
That lives in native-image.properties alongside the reachability metadata. The crash only appears under concurrent load. Single-threaded tests pass. Smoke test passes. Everything passes until it doesn’t.
R2DBC Pool: The Third Gotcha
Default R2DBC config: no pool limits. PostgreSQL default max_connections: 100. Under 50 concurrent requests:
sorry, too many clients already
Every reactive chain opens its own connection. No pool, no backpressure, no queuing — just a stampede into PostgreSQL’s connection limit.
Fix in application.yml:
r2dbc:
datasources:
default:
options:
pool:
maxSize: 20
initialSize: 5
This isn’t a native image problem. It’s a “defaults that work in development and detonate in production” problem. But it’s the kind of thing you discover during the same session as everything else, and it blurs together into “why is nothing working.”
The Dockerfile
Two-stage build. GraalVM builder, Debian slim runtime:
FROM ghcr.io/graalvm/graalvm-community:25.0.2 AS builder
RUN microdnf install -y findutils && microdnf clean all
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw && ./mvnw dependency:resolve dependency:resolve-plugins -q
COPY src/ src/
COPY aot-jar.properties aot-native-image.properties openapi.properties ./
RUN ./mvnw package -Dpackaging=native-image -DskipTests -q
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/micronaut-native-as-hell /app/micronaut-native-as-hell
ENTRYPOINT ["/app/micronaut-native-as-hell"]
Why debian:bookworm-slim and not alpine? GraalVM CE 25 builds glibc-linked binaries. Alpine is musl. The binary starts on Alpine. It even serves some requests. Then R2DBC connections fail silently, or DNS resolution produces wrong results, or java.lang.foreign calls segfault. None of these produce a clear error message.
Use glibc. Or build with --static --libc=musl if you need Alpine. There is no middle ground.
The findutils install on the builder is because mvnw calls find with flags that busybox find doesn’t support. That error, at least, is honest: find: unknown predicate.
The Reactive Pipeline
The collector orchestrates four parallel API calls, persists everything, handles partial failures:
Mono<Long> apodCount = apodService.fetchAndStore(sid, start, end)
.count()
.doOnNext(c -> { if (c == 0) appendWarning(warnings, "APOD: no data"); });
Mono<Long> neoCount = neoService.fetchAndStore(sid, start, end)
.count()
.doOnNext(c -> { if (c == 0) appendWarning(warnings, "NEO: no data"); });
Mono<Void> issMono = issTrackingService.captureCurrentPosition(sid)
.flatMap(weatherService::enrichWithWeather)
.switchIfEmpty(Mono.fromRunnable(
() -> appendWarning(warnings, "ISS/Weather: unavailable")))
.then();
return Mono.when(apodCount, neoCount, issMono)
.then(Mono.defer(() -> {
String w = warnings.get();
if (w.isEmpty()) {
return markCompleted(session, null);
}
return markCompleted(session, w);
}))
.onErrorResume(error -> markFailed(session, error.getMessage()));
Mono.when() is the join point. All three streams run concurrently. ISS API down? Session completes with a warning. NASA rate-limits? Partial data recorded.
The subtle trap: Mono.when() swallows individual results. It completes when all publishers complete, but doesn’t forward their values. The apodCount and neoCount monos emit counts into doOnNext callbacks, but Mono.when() itself emits Void. Need the counts downstream? Use Mono.zip(). The AtomicReference<String> for collecting warnings is the workaround — side effects in doOnNext because the join operator discards values.
This is not a Micronaut problem. This is a Reactor problem that Micronaut doesn’t warn you about.
@Scheduled Cron: Six Fields, Not Five
@Scheduled(cron = "0 0 6 * * *")
public void dailyCollection() {
// ...
}
Standard cron: five fields. Micronaut cron: six. Seconds, minutes, hours, day-of-month, month, day-of-week. The Spring format. Write 0 6 * * * (five fields, 6:00 AM) and Micronaut throws:
io.micronaut.scheduling.cron.CronExpression:
Invalid cron expression: 0 6 * * *
At startup. In native image. After a 90-second build. The error message is clear, at least.
SLF4J Compile Warnings
Every mvn compile:
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
The annotation processors (micronaut-inject-java, micronaut-data-processor, micronaut-serde-processor) log during compilation, and Logback isn’t on the annotation processor classpath. Fix:
<annotationProcessorPaths combine.self="override">
<!-- ... micronaut processors ... -->
<path>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.18</version>
</path>
</annotationProcessorPaths>
Not in the Micronaut 5 docs. Doesn’t break anything. Just noisy enough to make you question whether logging works at all.
AOT: Build-Time Everything
Micronaut AOT turns runtime decisions into build-time constants:
| Optimization | Effect |
|---|---|
cached.environment.enabled | Environment properties immutable after startup |
precompute.environment.properties.enabled | Config keys precomputed at build time |
logback.xml.to.java.enabled | Logback XML converted to Java config |
serviceloading.native.enabled | Service types scanned at build time |
scan.reactive.types.enabled | Reactive types resolved at build time |
The build takes ~90 seconds. The binary starts in under 1 second. 72 MB idle memory. Fair trade.
One combination to avoid: property-source-loader.generate + serviceloading.native both enabled. The native binary prints the Micronaut banner, then hangs. No error. No stacktrace. No crash. Just silence. Both are set to false in the production config now.
micronaut.aot.enabled defaults to false, and the documentation doesn’t emphasize how much startup time and memory it saves. Discovering it is left as an exercise.
Docker Compose
services:
postgres:
image: postgres:17-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U space -d space_observatory"]
interval: 5s
timeout: 3s
retries: 10
app:
build: .
mem_limit: ${MEM_LIMIT:-0}
memswap_limit: ${MEM_LIMIT:-0}
depends_on:
postgres:
condition: service_healthy
MEM_LIMIT defaults to 0 (unlimited). Set it to 128m for the constrained benchmark. memswap_limit matching mem_limit disables swap — when you hit the limit, you OOM or you cope. No gradual degradation into swap thrashing.
Load Test Results
1,000 requests x 20 concurrent, container-to-container via Apache Bench, against a seeded database. DB endpoints only — no external API calls.
| Endpoint | req/s | p50 | p99 |
|---|---|---|---|
| Observatory by code | 14,340 | 1 ms | 4 ms |
| All NEOs | 13,667 | 1 ms | 4 ms |
| NEO approaches | 14,425 | 1 ms | 4 ms |
| List observatories | 11,601 | 1 ms | 5 ms |
| List sessions | 10,035 | 1 ms | 15 ms |
| Session detail | 5,366 | 3 ms | 27 ms |
| Health | 7,408 | 1 ms | 24 ms |
131 MB binary. 72 MB idle. 113 MB under load. Sub-second startup. Zero OOM.
The session detail endpoint is slower — it joins 5 tables reactively, fetching APOD entries, asteroids, ISS positions, and weather observations in parallel, then assembling the response. 5,366 req/s for a 5-table fan-out join is fine.
What Actually Broke
Scorecard of things that cost time, ordered by frustration:
- Liquibase reflection metadata — 14 hand-crafted entries missed hundreds of types. Fix: tracing agent. Time wasted: too much.
- Arena.ofShared crash — silent under light load, fatal under concurrency. Fix:
-H:+SharedArenaSupport. Diagnosis: moderate, because the error message was good once it appeared. - Alpine musl — silent corruption across multiple subsystems. Fix: glibc base image. Diagnosis: significant, because nothing crashes cleanly.
- AOT silent hang — banner prints, then nothing. No error, no clue. Fix: disable
property-source-loader.generate+serviceloading.nativetogether. Diagnosis: trial and error. - R2DBC pool exhaustion — “sorry, too many clients already” under concurrency. Fix:
maxSize=20. Diagnosis: fast, clear error. - Mono.when swallowing results — not a bug, just a design that doesn’t match intuition. Fix: side-effect pattern with
AtomicReference. - 6-field cron — clear error, quick fix. Time wasted: one rebuild cycle.
- SLF4J warnings — cosmetic but confidence-eroding. Fix: add logback to annotation processor classpath.
Items 1-4 are silent failures. You get no error, or you get the error only under conditions your development setup never triggers. Item 5 gives you a clear error but only under load. Items 6-8 are normal day-one friction.
The pattern: every silent failure is a native image or concurrency issue. The loud failures are framework quirks. Loud is fine. Silent is where the hours go.
The Verdict
Micronaut 5.0.0 native image works. The app serves 14,000 req/s on DB endpoints, uses 72 MB idle, starts in under a second. The declarative clients are genuinely elegant. The compile-time DI model is sound.
But “first-class native image support” means “the framework does its part, and you handle the ecosystem.” Liquibase doesn’t know about native image. Netty’s Arena support needs a flag. Alpine compatibility requires explicit static linking. R2DBC pool defaults assume nobody will run more than a handful of connections. None of these are Micronaut’s fault, exactly. But they’re all Micronaut’s problem, because the developer chose Micronaut expecting the framework to handle this.
The tracing agent should be step one in every native image guide, not a footnote. The gap between 14 and 1,913 reflection entries is the gap between “it compiles” and “it works.”
Part 2 digs into what happens when you give this binary only 128 MB of RAM.
Sources
- Micronaut 5.0.0 Release Notes — release announcement
- Micronaut GraalVM Documentation — official native image guide
- netty/netty#15762 — SharedArena support issue
- GraalVM Reachability Metadata — tracing agent documentation
- Micronaut Serde — compile-time serialization
- Project Reactor Mono.when() — reactive composition documentation