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.
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:
- GraalVM’s own explanation of the closed-world assumption and reachability metadata: GraalVM Native Image basics.
- How this plays out operationally once you’re past the framework choice and into rollout mechanics: readiness probes, graceful shutdown and HPA on Spring Boot 4.
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.
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.
| Method | Path | Behaviour |
|---|---|---|
GET | /widgets | list all (seeded with 5) |
GET | /widgets/{id} | one widget, 404 if unknown |
POST | /widgets | create, 400 on blank name or negative quantity |
GET | health (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.
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.
| Framework | JVM startup | Native startup | Speedup | JVM RSS | Native RSS | Shrink |
|---|---|---|---|---|---|---|
| Spring Boot | 6.318s | 0.132s | 47.9× | 229.2 MB | 79.5 MB | 2.9× |
| Quarkus | 2.185s | 0.035s | 62.4× | 171.6 MB | 33.6 MB | 5.1× |
| Micronaut | 2.507s | 0.041s | 61.1× | 180.0 MB | 46.2 MB | 3.9× |
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:
- Spring Boot’s own AOT processing model, for the mechanism behind why it narrows but doesn’t erase this gap: Spring Boot GraalVM native image support.
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-timeto the wholech.qos.logbackpackage produced the exact same failure as attempt 1, because logback ships its ownnative-image.propertiesmetadata inside the jar, declaringLoggerspecifically 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.
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(orss -ltnpif you have it) showed a listener on hex1F90— 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.
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:
- Micronaut’s own configuration documentation, including the full loader list: Micronaut configuration guide.
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.
| Framework | Health endpoint | How it got there |
|---|---|---|
| Spring Boot | /actuator/health | spring-boot-starter-actuator, zero code |
| Quarkus | /q/health | quarkus-smallrye-health, zero code |
| Micronaut | /health | hand-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:
| Framework | JVM artifact | Native executable | Native build time |
|---|---|---|---|
| Spring Boot | 23 MB fat jar | 73 MB | 8m25s (first attempt) |
| Quarkus | 21 MB fast-jar dir | 44 MB | 6m22s (first attempt) |
| Micronaut | 14 MB shaded jar | 54 MB | 7m15s (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:
- Quarkus’s own native-image integration, including why it needs the fewest manual flags of the three: Quarkus building a native executable guide.
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.
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
- Companion repository, with the full source for all three apps, every benchmark script, and every raw captured output file referenced above: framework-benchmarks on Gitea
- Dockerizing Spring Boot 4: layered jars, buildpacks, and distroless — where a native executable’s image-size advantage is realized end to end
- Kubernetes probes, graceful shutdown, CPU limits and HPA on Spring Boot 4 — the autoscaling mechanics that make cold-start time matter in the first place
- Spring Boot 4 HTTP service clients — for the calling side of services like the one benchmarked here
- GraalVM Native Image: basics and the closed-world assumption
- GraalVM: Class Initialization in Native Image
- Quarkus: Building a Native Executable
- Spring Boot: Introducing GraalVM Native Images
- Micronaut: Configuration guide
- gillius.org’s independent Spring Boot / Quarkus / Micronaut benchmark — the cross-check referenced in the throughput section, on different hardware and with a database-backed app
No Comments yet!