Skip to main content

Spring Boot 4.1 vs Quarkus vs Micronaut: Startup, Memory and Throughput Benchmarked

One Widget REST API, built three times — Spring Boot 4.1.1, Quarkus 3.40.1, and Micronaut 5.2.2 — run in both JVM and GraalVM native mode, with real measured startup time, memory, throughput, artifact size and native build cost. Includes the four real native-image build failures it took to get Micronaut’s build working, and the config file that silently got ignored.

If you have shipped a Java service into a container in the last few years, you have probably had this exact conversation with someone: “Spring Boot is too slow to start, we should look at Quarkus or Micronaut.” Usually nobody in the room has actually measured anything — they are repeating a number from a conference talk about a different app, on different hardware, from a different year. Spring Boot 4.1 shipped in late September 2026, Quarkus 3.40 is the current LTS stream, and Micronaut 5.2 has been out since the summer, so the honest numbers from two years ago are stale even if they were right at the time.

This post is the conversation settled with code. One Widget REST API — same five seeded records, same validation rules, same JSON shapes, no database — built three times, once per framework, run in both ordinary JVM mode and as a GraalVM native image, and measured: cold-start time, resident memory, sustained throughput, deployment artifact size, and how much the native build itself costs you in build-time pain. Every number below comes from a script you can run yourself against the same repository.

Versions verified for this post. Spring Boot 4.1.1 (the current stable patch of the 4.1 line, GA September 2026), Quarkus 3.40.1 (the 3.40 LTS stream), Micronaut 5.2.2, all built and run on GraalVM CE 25.4.4.1.1 for JDK 25. Every version number was checked against Maven Central’s maven-metadata.xml for the respective artifact and cross-checked against each project’s own release page — not taken from a blog post or a “what’s new” article, several of which disagree with each other on patch numbers.

Three honest caveats before the numbers, because a benchmark that hides its limitations is worse than no benchmark. This runs on a 2-vCPU, 7.8 GiB container, not a beefy CI runner or your laptop — absolute numbers will shift on different hardware, though the relative story is unlikely to flip. There is no database in any of the three apps, on purpose: a JDBC driver and its native-image reflection metadata are a large enough topic on their own, and mixing them in here would make it impossible to tell whether a number is about the web framework or about the database integration. And the throughput test runs for fifteen seconds, which is long enough to see a JIT start warming up but not long enough to say where it ends up after an hour — that caveat gets its own section below, with the number that makes it concrete.

The problem: why startup time suddenly started mattering

For most of the JVM’s history, startup time was a one-time cost nobody timed. You started the app server once, in the morning, and it ran for weeks. Cold-start latency only mattered to people hacking on the JVM itself.

Two things changed that. Container orchestration made “start a new instance” a routine event instead of a rare one — a horizontal pod autoscaler in Kubernetes can decide to double your replica count in response to a traffic spike, and every one of those new pods has to actually become ready before it can take traffic. And serverless-style platforms made “start a new instance per request, then throw it away” the normal execution model for an entire category of workload. In both cases, the few seconds a JVM spends loading classes and letting the JIT warm up stops being invisible and starts being the latency the caller sees. If you’re tuning the autoscaling side of that problem rather than the framework side, the Kubernetes probes and HPA post covers the readiness-probe mechanics this startup time actually feeds into.

GraalVM’s native-image tool is the mainstream answer: instead of shipping bytecode that the JVM interprets and then JIT-compiles while the app is already supposedly serving traffic, you compile ahead-of-time, during the build, into a self-contained native executable that starts already-compiled. The number that will matter for the rest of this post, and the fact that makes it possible, is this: native-image can only do that by assuming it knows the whole program at build time — every class that will ever be loaded, every reflective call that will ever be made. That assumption is called the closed-world assumption, and it is the single idea this whole post keeps coming back to. Keep it in mind; it is the exact mechanism behind both the best numbers below and the worst build failure.

Same request, two ways of being ready for it JVM mode JVM boots classes load, beans wire JIT warms up (slow path) fast, JIT-compiled t≈6.3s Native image compiled ahead of time, during the build ready t≈0.13s What the build had to know in advance The native build can only pre-compile what it can prove is reachable. Every class loaded by reflection, every static initializer, every SPI lookup — all of it has to be accounted for during the build, or the binary is wrong or the build fails outright. That trade is the subject of the rest of this post.

The 47–62× startup speedups later in this post are the payoff of that trade. The build failures in the Micronaut section are the cost of it. Neither one is the whole story by itself.

Going deeper on this section:

The smallest correct mental model: two compilers, two moments

It helps to be precise about what “native” actually changes, because the word gets used loosely. In JVM mode, javac compiles your source to portable bytecode, and the JVM’s JIT compiler turns the hot parts of that bytecode into real machine code while the program runs, using information it only has at runtime — which methods are actually called a lot, which branches actually get taken. That is why a long-running JVM process eventually outperforms its own first few seconds: it has had time to specialize.

native-image moves that specialization to build time. It performs a whole-program static analysis called points-to analysis, starting from your main method and following every reachable call, to build the complete set of classes, methods and fields the program could possibly use. Everything in that reachable set gets compiled to machine code and baked into the executable, along with a snapshot of whatever static state you told it to initialize at build time. Everything outside that reachable set simply does not exist in the binary — there is no bytecode left to fall back to, no classloader to go fetch it from at runtime. That is the whole deal, and it is also exactly why reflection, dynamic proxies, and service-loader lookups need explicit configuration under native-image: the analysis has to be told about anything it cannot see by following ordinary method calls.

What the build-time analysis can and can’t see main() WidgetController WidgetStore reachable → compiled into the image Class.forName(name) ? — unknown until runtime analysis can’t prove this reachable → excluded unless you register it (reflect-config.json)

One consequence of this is where the title of “closed-world assumption” earns its name — and where two of the three build-time failures later in this post come from: class initialization timing. Every class has a static initializer, and native-image has to decide, per class, whether to run that initializer during the build (“build-time init”, the fast default for most classes) or defer it to when the program actually starts (“run-time init”, required for anything that touches real OS state — file handles, sockets, random seeds). Frameworks and libraries ship their own opinions about which of their classes need which treatment, via native-image.properties metadata bundled in the jar. When two libraries’ opinions collide on the same object graph, you get a build failure with a message that is accurate but, the first time you see it, deeply confusing. That exact collision is the subject of the section after the benchmark numbers.

“Closed world” is the load-bearing phrase in this whole post. Every surprising thing that happens later — the startup numbers, the memory numbers, the Micronaut build failures, the config file that got silently ignored — traces back to the fact that a native-image build has to know everything in advance, and anything it can’t prove at build time either gets explicitly configured or doesn’t exist in the binary.

Micronaut and Quarkus both lean into this harder than Spring Boot does, by generating most of their dependency-injection and serialization code at compile time rather than discovering it via classpath scanning and reflection at startup — which is also why, as you’ll see in the numbers, they both start faster even in plain JVM mode, before native-image enters the picture at all. Spring Boot 4.1 narrows that gap but does not close it, because Spring’s component model is still fundamentally reflection-based underneath the AOT processing it has added since Boot 3.

Going deeper on this section:

  • Full detail on build-time vs. run-time initialization semantics: GraalVM Class Initialization reference.
  • The concrete, verbatim build failure this causes with Netty and Logback is worked through in full after the benchmark numbers below — this is the fact being planted for that payoff.

The smallest thing that works: one Widget API, three times

The repository is three independent Maven projects — spring-boot-app, quarkus-app, micronaut-app — each exposing the identical contract: list widgets, get one by id (404 if missing), create one (400 on a blank name or negative quantity), and a health check. No shared parent POM, no shared code; three teams implementing the same spec would produce roughly this.

MethodPathBehaviour
GET/widgetslist all (seeded with 5)
GET/widgets/{id}one widget, 404 if unknown
POST/widgetscreate, 400 on blank name or negative quantity
GEThealth (each framework’s own idiom)trivial liveness — deliberately not forced identical; see the section on defaults below

Here is the same endpoint, in each framework’s own idiom. Spring MVC’s annotation style first:

@RestController
@RequestMapping("/widgets")
public class WidgetController {

    private final WidgetStore store;

    public WidgetController(WidgetStore store) {
        this.store = store;
    }

    @GetMapping
    public Collection<Widget> all() {
        return store.findAll();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Widget> one(@PathVariable String id) {
        return store.findById(id)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<Widget> create(@Valid @RequestBody CreateWidgetRequest request) {
        Widget created = store.create(request.name(), request.quantity());
        return ResponseEntity.created(URI.create("/widgets/" + created.id())).body(created);
    }
}

Full file: spring-boot-app/…/WidgetController.java.

Quarkus uses plain JAX-RS instead of a Spring-specific annotation set — Jakarta REST, with Quarkus REST (the Vert.x-backed implementation that replaced RESTEasy Classic as the default) doing the dispatching:

@Path("/widgets")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class WidgetResource {

    @Inject
    WidgetStore store;

    @GET
    public Collection<Widget> all() {
        return store.findAll();
    }

    @GET
    @Path("/{id}")
    public Response one(@PathParam("id") String id) {
        return store.findById(id)
                .map(w -> Response.ok(w).build())
                .orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build());
    }

    @POST
    public Response create(@Valid CreateWidgetRequest request) {
        Widget created = store.create(request.name(), request.quantity());
        return Response.created(URI.create("/widgets/" + created.id())).entity(created).build();
    }
}

Full file: quarkus-app/…/WidgetResource.java.

And Micronaut, which looks the most like Spring MVC syntactically — constructor injection, @Controller instead of @RestController — but, underneath, generates the routing and serialization code at compile time instead of discovering it by scanning at startup:

@Controller("/widgets")
@Validated
public class WidgetController {

    private final WidgetStore store;

    public WidgetController(WidgetStore store) {
        this.store = store;
    }

    @Get
    public Collection<Widget> all() {
        return store.findAll();
    }

    @Get("/{id}")
    public HttpResponse<Widget> one(@PathVariable String id) {
        return store.findById(id)
                .map(HttpResponse::ok)
                .orElseGet(() -> HttpResponse.notFound());
    }

    @Post
    public HttpResponse<Widget> create(@Valid @Body CreateWidgetRequest request) {
        Widget created = store.create(request.name(), request.quantity());
        return HttpResponse.created(created, URI.create("/widgets/" + created.id()));
    }
}

Full file: micronaut-app/…/WidgetController.java.

Build and run any of them in plain JVM mode and you get a listening port in a couple of seconds. Here is the real output of scripts/measure.py, which starts the jar, polls /widgets until it answers 200, records wall-clock startup time and the process’s resident memory from /proc/[pid]/status, kills it, and repeats — four times per framework, discarding the first cold-cache run and averaging the rest:

=== Spring Boot JVM startup+memory (4 runs) ===
{"label": "spring-boot-jvm", "ready": true, "served_ok": true, "startup_s": 6.358, "rss_kb": 234664, "run": 2, "discarded": false}
{"label": "spring-boot-jvm", "ready": true, "served_ok": true, "startup_s": 6.308, "rss_kb": 236924, "run": 3, "discarded": false}
{"label": "spring-boot-jvm", "ready": true, "served_ok": true, "startup_s": 6.287, "rss_kb": 232612, "run": 4, "discarded": false}
{"label": "spring-boot-jvm", "summary": true, "runs_kept": 3, "avg_startup_s": 6.318, "avg_rss_kb": 234733.3, "avg_rss_mb": 229.2}
=== Quarkus JVM startup+memory (4 runs) ===
{"label": "quarkus-jvm", "ready": true, "served_ok": true, "startup_s": 2.084, "rss_kb": 175640, "run": 2, "discarded": false}
{"label": "quarkus-jvm", "ready": true, "served_ok": true, "startup_s": 2.19, "rss_kb": 176004, "run": 3, "discarded": false}
{"label": "quarkus-jvm", "ready": true, "served_ok": true, "startup_s": 2.282, "rss_kb": 175364, "run": 4, "discarded": false}
{"label": "quarkus-jvm", "summary": true, "runs_kept": 3, "avg_startup_s": 2.185, "avg_rss_kb": 175669.3, "avg_rss_mb": 171.6}
=== Micronaut JVM startup+memory (4 runs) ===
{"label": "micronaut-jvm", "ready": true, "served_ok": true, "startup_s": 2.648, "rss_kb": 184576, "run": 2, "discarded": false}
{"label": "micronaut-jvm", "ready": true, "served_ok": true, "startup_s": 2.372, "rss_kb": 183996, "run": 3, "discarded": false}
{"label": "micronaut-jvm", "ready": true, "served_ok": true, "startup_s": 2.502, "rss_kb": 184316, "run": 4, "discarded": false}
{"label": "micronaut-jvm", "summary": true, "runs_kept": 3, "avg_startup_s": 2.507, "avg_rss_kb": 184296.0, "avg_rss_mb": 180.0}

Full transcript: docs/output/01-startup-memory-jvm.txt. Even before native-image enters the picture, Quarkus and Micronaut start roughly three times faster than Spring Boot in plain JVM mode — the compile-time wiring mentioned in the previous section, paying off already.

JVM-mode cold start (seconds, lower is faster) spring-boot 6.318s quarkus 2.185s micronaut 2.507s scale: 50px ≈ 1 second
Going deeper: how measure.py decides a process is “ready”, and why that matters

The trap in any startup-time benchmark is the definition of “started”. A process that has merely forked is not started; a process whose log line says Started Application might print that line before its embedded server has actually bound the port, especially under Quarkus and Micronaut, which log less and finish wiring routes closer to the end of their boot sequence than Spring Boot does.

measure.py avoids the log-line trap entirely: it launches the process, then polls the actual HTTP endpoint in a tight loop (200ms sleep, 2s timeout per attempt, capped retries) until it gets back a real 200 from /widgets — not from an actuator or health path, the real business endpoint the benchmark cares about. “Started” is defined as “answered the request correctly”, which is the only definition a caller would accept. Memory (rss_kb) is read from /proc/[pid]/status’s VmRSS line immediately after that first successful response, which is also the moment a real autoscaler would consider the instance ready to receive traffic.

The first of the four runs for each framework is discarded and excluded from every average in this post. That run includes page-cache misses for the jar and shared libraries that the following three don’t pay, and including it would have inflated every startup number by roughly 5–8% in testing — a filesystem-caching effect, not a framework difference, and worth excluding for exactly that reason.

Full source: scripts/measure.py.

Going deeper on this section:

  • Full reproducible benchmark driver, including the native-mode variants further down: scripts/run-all.sh.
  • Quarkus REST’s own explanation of why it replaced RESTEasy Classic as the default: Quarkus REST guide.

Flipping to native: what the same four runs look like compiled ahead of time

Same apps, same measure.py script, same methodology — just pointed at the native executable instead of java -jar. The only code that changed between this run and the JVM-mode one above is the build command.

=== Spring Boot NATIVE startup+memory (4 runs) ===
{"label": "spring-boot-native", "ready": true, "served_ok": true, "startup_s": 0.133, "rss_kb": 81384, "run": 2, "discarded": false}
{"label": "spring-boot-native", "ready": true, "served_ok": true, "startup_s": 0.121, "rss_kb": 81408, "run": 3, "discarded": false}
{"label": "spring-boot-native", "ready": true, "served_ok": true, "startup_s": 0.143, "rss_kb": 81408, "run": 4, "discarded": false}
{"label": "spring-boot-native", "summary": true, "runs_kept": 3, "avg_startup_s": 0.132, "avg_rss_kb": 81400.0, "avg_rss_mb": 79.5}
=== Quarkus NATIVE startup+memory (4 runs) ===
{"label": "quarkus-native", "ready": true, "served_ok": true, "startup_s": 0.035, "rss_kb": 34368, "run": 2, "discarded": false}
{"label": "quarkus-native", "ready": true, "served_ok": true, "startup_s": 0.036, "rss_kb": 34348, "run": 3, "discarded": false}
{"label": "quarkus-native", "ready": true, "served_ok": true, "startup_s": 0.034, "rss_kb": 34356, "run": 4, "discarded": false}
{"label": "quarkus-native", "summary": true, "runs_kept": 3, "avg_startup_s": 0.035, "avg_rss_kb": 34357.3, "avg_rss_mb": 33.6}
=== Micronaut NATIVE startup+memory (4 runs) ===
{"label": "micronaut-native", "ready": true, "served_ok": true, "startup_s": 0.037, "rss_kb": 47304, "run": 2, "discarded": false}
{"label": "micronaut-native", "ready": true, "served_ok": true, "startup_s": 0.05, "rss_kb": 47328, "run": 3, "discarded": false}
{"label": "micronaut-native", "ready": true, "served_ok": true, "startup_s": 0.036, "rss_kb": 47324, "run": 4, "discarded": false}
{"label": "micronaut-native", "summary": true, "runs_kept": 3, "avg_startup_s": 0.041, "avg_rss_kb": 47318.7, "avg_rss_mb": 46.2}

Full transcript: docs/output/03-startup-memory-native.txt.

FrameworkJVM startupNative startupSpeedupJVM RSSNative RSSShrink
Spring Boot6.318s0.132s47.9×229.2 MB79.5 MB2.9×
Quarkus2.185s0.035s62.4×171.6 MB33.6 MB5.1×
Micronaut2.507s0.041s61.1×180.0 MB46.2 MB3.9×
Resident memory, JVM vs native (MB, lower is smaller) spring-boot 229.2 79.5 quarkus 171.6 33.6 micronaut 180.0 46.2 JVM mode native mode scale: 1px ≈ 1 MB

Quarkus shrinks the most proportionally on memory, which lines up with it being the only one of the three that also uses a reactive, non-blocking I/O model by default (Vert.x underneath), needing fewer threads and therefore less per-thread stack memory even before native-image’s own savings are counted. Spring Boot shrinks the least proportionally, which lines up with it carrying the most reflection-dependent machinery of the three into the image — Spring’s AOT processing eliminates a great deal of startup-time reflection, but the resulting image still ships more retained metadata than the other two.

Going deeper on this section:

What breaks when you flip to native: four tries at one Micronaut build

This is the section the closed-world-assumption detour earlier was building toward. Getting Spring Boot and Quarkus to produce a native image was uneventful — both succeeded on the first attempt, once the right Maven goal was actually invoked (more on that below). Micronaut took four attempts, and the four failures are a genuinely good illustration of what “everything has to be known at build time” costs you in practice, because each fix revealed a slightly deeper layer of the same conflict.

The short version: Netty wants its leak detector built eagerly, logback wants its Logger initialized at run time, and the two defaults only collide when something forces Netty’s buffer allocator to be build-time-reachable. Here is that collision, attempt by attempt, quoted verbatim from the real native-image invocations and the full committed transcript at docs/output/06-micronaut-native-build-failures.txt — nothing below has been paraphrased or tidied:

=== Attempt 1 (no initialization args at all) ===
Failed generating 'micronaut-app' after 42.4s.

> com.oracle.graal.pointsto.util.ParallelExecutionException: An object of type 'ch.qos.logback.classic.Logger' was found in the image heap. This type, however, is marked for initialization at image run time for the following reason: classes are initialized at run time by default.
This is not allowed for correctness reasons: All objects that are stored in the image heap must be initialized at build time.
...
Object was reached by
  scanning root constant ch.qos.logback.classic.Logger@7f585330: Logger[io.netty.util.ResourceLeakDetector] embedded in
    <unknown>
  at io.netty.util.ResourceLeakDetector.needReport() [bci: -5]

Reasonable first guess: tell the build logback’s Logger class is fine to initialize at build time, since that is the class the error names.

=== Attempt 2 (--initialize-at-build-time=ch.qos.logback.classic.Logger) ===
Failed generating 'micronaut-app' after 46.0s.

> com.oracle.graal.pointsto.util.ParallelExecutionException: An object of type 'ch.qos.logback.classic.LoggerContext' was found in the image heap. This type, however, is marked for initialization at image run time ...
Object was reached by
  reading field ch.qos.logback.classic.Logger.loggerContext of constant
    ch.qos.logback.classic.Logger@6f5cb3e9: Logger[io.netty.util.ResourceLeakDetector]
  scanning root constant ch.qos.logback.classic.Logger@6f5cb3e9: Logger[io.netty.util.ResourceLeakDetector] embedded in
    <unknown>
  at io.netty.util.ResourceLeakDetector.needReport() [bci: -5]

Same object, one field deeper — fixing Logger just exposed the LoggerContext it holds a reference to (attempt 2 of 4 in the committed transcript). The obvious next move is to stop playing whack-a-mole on individual fields and widen the net to the whole package:

=== Attempt 3 (--initialize-at-build-time=ch.qos.logback) ===
Failed generating 'micronaut-app' after 49.9s.

> com.oracle.graal.pointsto.util.ParallelExecutionException: An object of type 'ch.qos.logback.classic.Logger' was found in the image heap. This type, however, is marked for initialization at image run time ...
  (identical trace to Attempt 1)
The surprise in attempt 3: a broader directive lost to a narrower one. Widening --initialize-at-build-time to the whole ch.qos.logback package produced the exact same failure as attempt 1, because logback ships its own native-image.properties metadata inside the jar, declaring Logger specifically as run-time-init. GraalVM lets a more specific per-class directive override a broader package-level one — in either direction, your command-line flag or the library’s own bundled metadata, whichever is more specific wins. Fighting a library’s own native-image metadata with a broader flag from outside does not work; you have to go around the conflict, not through it.

Going around it means stopping at the field that actually points to real OS state and asking why it’s reachable at build time in the first place, instead of what to initialize when:

=== Attempt 4 (--initialize-at-run-time=io.netty.util.ResourceLeakDetector,io.netty.util.ResourceLeakDetectorFactory) ===
Failed generating 'micronaut-app' after 42.9s.

> com.oracle.graal.pointsto.util.ParallelExecutionException: An object of type 'io.netty.util.ResourceLeakDetector' was found in the image heap. This type, however, is marked for initialization at image run time ...
Object was reached by
  scanning root constant io.netty.util.ResourceLeakDetector@2a2de2c0: io.netty.util.ResourceLeakDetector@2a2de2c0 embedded in
    io.netty.buffer.AbstractByteBufAllocator.toLeakAwareBuffer(AbstractByteBufAllocator.java:53)
  parsing method io.netty.buffer.AbstractByteBufAllocator.toLeakAwareBuffer(AbstractByteBufAllocator.java:53) reachable via the parsing context
    at static root method.(Unknown Source)

Deferring just the leak detector to run time wasn’t enough, because AbstractByteBufAllocator — itself build-time-initialized by default, since it holds no OS state on its own — keeps a static reference to a leak detector instance, and that reference is exactly the embedding path the analysis just walked. The fix that actually worked gave up on chasing individual classes and deferred the entire package:

=== Fix that worked (--initialize-at-run-time=io.netty) ===
Finished generating 'micronaut-app' in 7m 15s.
  2,621 types -> 3,395 types reachable after the fix (more code stays reachable because
  initialization now genuinely happens at run time instead of being folded away), image
  heap 854MB peak RSS during the build, final executable 53.31MiB.

Full verbatim transcript of all four attempts: docs/output/06-micronaut-native-build-failures.txt. The working build argument lives in the module’s native profile:

<plugin>
  <groupId>org.graalvm.buildtools</groupId>
  <artifactId>native-maven-plugin</artifactId>
  <configuration>
    <imageName>micronaut-app</imageName>
    <mainClass>com.ankurm.benchmarks.micronaut.Application</mainClass>
    <buildArgs>
      <!-- Netty's ResourceLeakDetector holds a static reference to a
           ch.qos.logback.classic.Logger; logback is run-time-init by
           default, which GraalVM refuses to embed in the image heap. -->
      <buildArg>--initialize-at-run-time=io.netty</buildArg>
    </buildArgs>
  </configuration>
</plugin>

Full file: micronaut-app/pom.xml.

AbstractByteBufAllocator build-time init (Netty default) holds ResourceLeakDetector holds logback Logger run-time init (logback’s own metadata) LoggerContext build-time class reaches a run-time-only object → GraalVM refuses to embed it in the image heap. Fix: push the whole io.netty package to run-time init.
Going deeper: the Maven goal-binding trap that cost a build before any of this

Before any of the four failures above, nothing happened at all. Declaring the native-maven-plugin inside a <profile> — the pattern copied from most tutorials — does not bind it to any Maven phase unless you also give it an <executions> block. Running mvn -Pnative package on Micronaut silently produced an ordinary jar and an empty target/native/ directory, with no error, no warning, nothing to indicate the native build never ran. The same is true of Spring Boot’s own native profile.

The fix for both is to invoke the goal explicitly rather than relying on phase binding:

$ mvn -Pnative package org.graalvm.buildtools:native-maven-plugin:compile -DskipTests   # Micronaut
$ mvn -Pnative native:compile                                                            # Spring Boot (its own plugin ID)

Quarkus doesn’t have this trap — its own Maven plugin binds the native build into the ordinary package phase when quarkus.native.enabled=true is set, which is also why its native profile is the shortest of the three in the repo. The exact working commands for all three modules, including this explicit goal invocation, are committed in scripts/run-all.sh.

Going deeper on this section:

  • Full native-image CLI reference for --initialize-at-build-time / --initialize-at-run-time: GraalVM Native Image build options.
  • Netty project home, for anyone hitting a similar collision with a different logging backend and wanting to check current native-image support status: netty.io.

What breaks when a default is just missing: the config file nobody read

The Netty/logback collision at least fails loudly. This one didn’t fail at all — the app looked hung, with no error anywhere, which is a worse failure mode because there is nothing to search for.

The Micronaut module was originally configured with application.yml, matching the other two modules’ application.properties only in spirit. The shaded jar built cleanly and ran without a stack trace, but measure.py never saw port 8083 come up, and a manual curl to it hung until the connection timed out. A thread dump showed nothing wrong — just idle Netty event-loop threads waiting for work, which is what a perfectly healthy, perfectly idle server looks like.

The fingerprint of this exact bug: a Micronaut app that starts clean, logs normally, and never answers on the port you configured. Check whether it’s actually listening somewhere else before assuming the app is stuck. Reading /proc/[pid]/net/tcp (or ss -ltnp if you have it) showed a listener on hex 1F90 — 8080 in decimal, Micronaut’s built-in default — instead of the 8083 the YAML file supposedly configured.

The root cause: micronaut-http-server-netty does not pull in YAML parsing as a transitive dependency. micronaut-yaml (a thin wrapper around SnakeYAML) has to be added explicitly, and without it Micronaut doesn’t error on an application.yml it can’t parse — it simply skips that configuration source entirely, the same way it would skip a config file that legitimately doesn’t exist, and falls back to every framework default. Port 8083 was never “wrong”; it was never read in the first place.

application.yml (port: 8083) no micronaut-yaml skipped, no warning falls back to :8080 application.properties built-in parser read successfully binds :8083 correctly Same key, same value, same server — the only difference is whether the file format parser is even on the classpath.

The working configuration, switched to properties to match the other two modules for a fair comparison:

micronaut.application.name=micronautApp
micronaut.server.port=8083
endpoints.health.enabled=true
endpoints.health.sensitive=false

Full file: micronaut-app/…/application.properties.

Going deeper: Micronaut’s config-loading dependency surface, in general

Properties, environment variables and system properties are handled by Micronaut’s core PropertySourceLoader with no extra dependency. YAML, TOML and Groovy config each need their own loader module (micronaut-yaml, etc.) added explicitly, and the loading behaviour on a missing loader is the same silent skip described above regardless of which format you pick — this is a general property of the mechanism, not a YAML-specific bug.

The practical takeaway for anyone hitting an unexplained default value in a Micronaut app: before assuming the value is wrong, confirm the file is actually being read at all, ideally by adding a value with no plausible default (a random UUID works well) and checking whether it shows up in the running app’s environment endpoint.

Going deeper on this section:

Throughput: does native still win once the JVM has somewhere to warm up to?

Startup and memory are the headline reason people reach for native image, but it is worth asking the other obvious question: once a JVM process has been running for a while and the JIT has had time to specialize, does it catch up on raw throughput? scripts/throughput.py answers it for a 15-second window: twenty concurrent workers, each a persistent http.client.HTTPConnection hammering GET /widgets, aggregated into requests/sec and latency percentiles.

=== Spring Boot JVM throughput (15s, concurrency 20) ===
{
  "requests_ok": 34158,
  "requests_per_sec": 2275.7,
  "latency_ms_p50": 5.6,
  "latency_ms_p95": 26.83,
  "latency_ms_p99": 57.06
}
=== Spring Boot NATIVE throughput (15s, concurrency 20) ===
{
  "requests_ok": 71947,
  "requests_per_sec": 4795.1,
  "latency_ms_p50": 3.37,
  "latency_ms_p95": 10.17,
  "latency_ms_p99": 18.1
}
=== Quarkus JVM throughput (15s, concurrency 20) ===
{
  "requests_ok": 56912,
  "requests_per_sec": 3791.4,
  "latency_ms_p50": 4.6,
  "latency_ms_p95": 10.55,
  "latency_ms_p99": 19.49
}
=== Quarkus NATIVE throughput (15s, concurrency 20) ===
{
  "requests_ok": 63690,
  "requests_per_sec": 4243.8,
  "latency_ms_p50": 3.81,
  "latency_ms_p95": 11.56,
  "latency_ms_p99": 16.78
}
=== Micronaut JVM throughput (15s, concurrency 20) ===
{
  "requests_ok": 64180,
  "requests_per_sec": 4276.2,
  "latency_ms_p50": 3.99,
  "latency_ms_p95": 11.52,
  "latency_ms_p99": 19.7
}
=== Micronaut NATIVE throughput (15s, concurrency 20) ===
{
  "requests_ok": 74439,
  "requests_per_sec": 4961.2,
  "latency_ms_p50": 3.01,
  "latency_ms_p95": 10.41,
  "latency_ms_p99": 15.85
}

Full transcripts: docs/output/02-throughput-jvm.txt and 04-throughput-native.txt. Native wins throughput too, in every framework, by 10–111% — Spring Boot’s native mode more than doubles its own JVM-mode number, the largest swing of the three, consistent with Spring MVC’s servlet stack carrying more reflection and dynamic-proxy overhead that the AOT build gets to eliminate once and for all at build time rather than re-paying on every request.

What this fifteen-second window cannot tell you. It cannot say whether a JVM, given minutes instead of seconds, eventually equals or exceeds native throughput on this exact workload — C2’s profile-guided optimizations can out-specialize an AOT build’s build-time-only view once a process has run long enough to see real production traffic shapes. What it can say is that for any workload whose lifetime is itself measured in seconds — a scale-to-zero function, a short-lived batch job, a CLI invocation — the JVM never gets there, because it never gets the time. The independent benchmark by gillius.org (different hardware, a Postgres-backed app, and the previous major versions of each framework) found the same qualitative pattern across a longer run: native wins the metrics that matter for short-lived processes by roughly one to two orders of magnitude, and the gap on steady-state throughput is real but much smaller and sometimes workload-dependent.

Going deeper on this section:

  • Full concurrent load generator, including how persistent connections and worker-thread aggregation are implemented without any third-party HTTP client: scripts/throughput.py.

What the defaults don’t do: health checks, image size, and the native build tax

Startup, memory and throughput are the numbers everyone asks for. Three things nobody asks about up front turned out to matter just as much while actually building this repository.

Health checks are not a solved, identical problem across the three. Spring Boot Actuator and Quarkus SmallRye Health both give you a working /health-style endpoint for one dependency and effectively zero configuration. Micronaut was supposed to as well, via micronaut-management — except that artifact had no version Maven could resolve as managed under micronaut-parent:5.2.2 in this setup, a dependency that plenty of Micronaut 5 release-notes prose assumes you’ll just add. The fix here was to drop it and hand-write a two-line health controller instead:

@Controller("/health")
public class HealthController {

    @Get
    public HttpResponse<Map<String, String>> health() {
        return HttpResponse.ok(Map.of("status", "UP"));
    }
}

That is a fine outcome for a benchmark that just needs a liveness probe — but it is also the honest answer to “which of these three has the most mature out-of-the-box observability story”, and it is not the one with the fewest lines of application code to write otherwise.

FrameworkHealth endpointHow it got there
Spring Boot/actuator/healthspring-boot-starter-actuator, zero code
Quarkus/q/healthquarkus-smallrye-health, zero code
Micronaut/healthhand-rolled controller — micronaut-management’s managed version didn’t resolve

Artifact size and native build time are the other two numbers worth seeing before picking a side:

FrameworkJVM artifactNative executableNative build time
Spring Boot23 MB fat jar73 MB8m25s (first attempt)
Quarkus21 MB fast-jar dir44 MB6m22s (first attempt)
Micronaut14 MB shaded jar54 MB7m15s (fourth attempt — three earlier ones failed in 42–50s each)

Full source: docs/output/05-summary.txt. The native executable is always bigger than the JVM artifact — it is carrying a statically-linked runtime and the compiled machine code for everything reachable, instead of portable bytecode a separately-installed JVM interprets. On a 2-vCPU sandbox, every native build costs six to eight and a half minutes of wall clock, and native-image is CPU-bound, so that number compresses meaningfully on a bigger CI runner with more cores to parallelize the points-to analysis across — but it never goes to zero, and it never gets fast enough to run on every commit the way a JVM-mode mvn package does.

The real cost of the Micronaut build failures above wasn’t the three minutes of wasted wall-clock. It was the three separate round-trips of reading an unfamiliar error, forming a hypothesis, and waiting another 40-second build to find out it was wrong. If your CI pipeline runs a native build on every PR, a static-init conflict like this one turns into a multi-hour debugging session spread across several pushes, because each iteration costs a full build cycle just to tell you whether your fix worked — which is itself an argument for working it out locally, as above, before it ever reaches CI.

If you are packaging any of these three for a container, the Dockerizing Spring Boot 4 post covers layered jars, buildpacks and distroless base images — a native executable pairs especially well with a distroless or scratch base image, since it needs no JVM in the container at all, which is most of where the deployment-artifact story for native image actually pays off beyond the raw executable-size number above.

Going deeper: the native-image build flags this repo actually needed, and why each framework needed a different number of them

Quarkus needed zero manual native-image flags — its Maven plugin and its extensions ship enough reachability metadata and initialization directives between them that the build just works from -Pnative package alone. Spring Boot needed none either, once the goal-binding trap above was worked around; Spring’s AOT processing generates the reachability hints it needs at build time as part of the same Maven build. Micronaut needed exactly one, --initialize-at-run-time=io.netty, but needed four attempts to arrive at it, for the reasons walked through in the section above.

The general pattern worth taking away: a framework that does more of its own code-generation and reachability-metadata authoring at compile time (Quarkus, and to a slightly lesser extent Micronaut and Spring Boot’s AOT mode) needs fewer manual native-image flags, because the framework itself is doing part of what native-image would otherwise have to infer. The flags you do end up needing tend to live at exactly the seams between two libraries’ independently-authored assumptions — which is why the one flag this repo needed came from a Netty/logback interaction neither library’s own metadata alone could have told you about.

Going deeper on this section:

Whether you should actually do this

None of the numbers above are an argument for always reaching for native-image, and they are not an argument for always avoiding it either — they are an argument for checking which bucket your workload actually falls into before you pay the build-time cost.

Native image earns its cost when a process’s lifetime is short enough that startup time is a meaningful fraction of the total time it runs: a function-as-a-service handler that scales to zero between invocations, a CLI tool invoked once per command, a batch job that spins up, does one thing, and exits, or a Kubernetes deployment that autoscales aggressively enough that new pods routinely need to be useful within a second or two of being scheduled. In every one of those cases, the 47–62× startup improvement and the 2.9–5.1× memory reduction measured above are not academic — they are directly how many instances you can run per dollar, or how many requests queue up waiting for a cold pod.

It costs more than it returns when a process runs for hours or days at a time and the fleet size is roughly fixed: the few seconds saved on a cold start that happens once a month during a deploy are not worth the six-to-eight-plus minutes of CI time on every build, the loss of familiar JVM tooling — no hot-swap, more limited profiler and debugger attach, a different and less mature escape hatch for “why is this reflective call not working” — and, as this repository found directly, the possibility of a multi-attempt debugging session the first time two of your dependencies disagree about class initialization timing.

Is process lifetime short relative to startup time? yes Serverless, CLI, batch jobs, aggressive autoscaling → native image is worth the build cost no Long-running services, roughly fixed fleet size → plain JVM mode is usually the better trade Framework choice (Spring Boot / Quarkus / Micronaut) is a separate axis from this one — all three are viable in either bucket; they differ mainly in how much of the native-build pain they absorb for you.
If you’re choosing a framework, not just a runtime mode, the honest summary is this. Quarkus had the smoothest native build of the three and the best memory efficiency, largely from its reactive-by-default I/O model. Micronaut had the best raw throughput in both modes and syntax closest to Spring MVC, at the cost of the thinnest out-of-the-box observability story and the one real native-build gotcha in this whole exercise. Spring Boot is the slowest to start and the heaviest in memory in every configuration tested, native included — and remains, for most teams, the framework whose ecosystem, documentation, and hiring pool make that cost worth paying anyway for anything that isn’t startup-latency-critical. None of the three was unable to do the job; they trade off differently, and the trade only matters if your workload is in the bucket where startup time is the thing you’re optimizing.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.