Micronaut 5 Native Image: 14 Entries vs 1,913

The Memory Benchmark · Part 1

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:

OptimizationEffect
cached.environment.enabledEnvironment properties immutable after startup
precompute.environment.properties.enabledConfig keys precomputed at build time
logback.xml.to.java.enabledLogback XML converted to Java config
serviceloading.native.enabledService types scanned at build time
scan.reactive.types.enabledReactive 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.

Endpointreq/sp50p99
Observatory by code14,3401 ms4 ms
All NEOs13,6671 ms4 ms
NEO approaches14,4251 ms4 ms
List observatories11,6011 ms5 ms
List sessions10,0351 ms15 ms
Session detail5,3663 ms27 ms
Health7,4081 ms24 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:

  1. Liquibase reflection metadata — 14 hand-crafted entries missed hundreds of types. Fix: tracing agent. Time wasted: too much.
  2. Arena.ofShared crash — silent under light load, fatal under concurrency. Fix: -H:+SharedArenaSupport. Diagnosis: moderate, because the error message was good once it appeared.
  3. Alpine musl — silent corruption across multiple subsystems. Fix: glibc base image. Diagnosis: significant, because nothing crashes cleanly.
  4. AOT silent hang — banner prints, then nothing. No error, no clue. Fix: disable property-source-loader.generate + serviceloading.native together. Diagnosis: trial and error.
  5. R2DBC pool exhaustion — “sorry, too many clients already” under concurrency. Fix: maxSize=20. Diagnosis: fast, clear error.
  6. Mono.when swallowing results — not a bug, just a design that doesn’t match intuition. Fix: side-effect pattern with AtomicReference.
  7. 6-field cron — clear error, quick fix. Time wasted: one rebuild cycle.
  8. 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