Quarkus Native Mode: When the Build-Time Trade Actually Pays Off
Native mode changes what a Quarkus app may do at runtime. Here is where reflection breaks, how to register it, and how to measure whether the trade pays off.
23 Sept 2025, 01:17 UTC

A startup tax you pay on every scale-out
A JVM service can be perfectly healthy at steady state and still be a poor fit for a platform that starts and stops instances constantly: scale-to-zero containers, short-lived batch jobs, functions invoked a few times a minute. Before the JVM does useful work it loads and verifies classes and warms up the JIT compiler. Quarkus native mode moves that work to build time and ships a self-contained executable instead.
The catch is that native mode is not a runtime flag. It changes what the application is allowed to do once it is running.
The thesis: native mode is a build-time contract
GraalVM's ahead-of-time (AOT) compiler builds a closed world. It decides at build time which classes, methods, and resources end up in the image. Anything the application discovers only at runtime — a class name read from a configuration file, a dynamic proxy created by a framework, bytecode generated on the fly — must be declared in advance or it is simply not there.
Quarkus extensions do most of that declaring for you. Failures cluster in the gaps: code paths that reach for a class the build could not see.
Where the closed world bites
Three patterns account for most native-only failures:
- Reflection by name.
Class.forName(someString)where the string comes from config, a database row, or a plugin manifest. - Dynamic proxies. Frameworks that generate an implementation at runtime need it registered, or need a Quarkus extension that does it for them.
- Runtime bytecode generation. Libraries that emit or rewrite classes at runtime generally have no native equivalent.
The first two are fixable with configuration. The third usually means staying on the JVM.
Worked example: a rule class resolved by name
Suppose a pricing service picks its rule implementation from configuration:
@ApplicationScoped
public class RuleLoader {
@ConfigProperty(name = "pricing.rule-class")
String ruleClassName; // e.g. com.example.pricing.LegacyPriceRule
public PriceRule load() throws Exception {
Class<?> type = Class.forName(ruleClassName);
return (PriceRule) type.getDeclaredConstructor().newInstance();
}
}
In JVM mode this works. In a native image the class is absent unless something registers it, because the build never sees the string that names it. If the class is yours, annotate it:
@RegisterForReflection
public class LegacyPriceRule implements PriceRule {
public String sku;
public long cents;
}
If the class lives in a third-party jar you cannot annotate, register it from your own code instead:
@RegisterForReflection(targets = com.thirdparty.LegacyPriceRule.class)
public class NativeReflectionConfig {
}
@RegisterForReflection has attributes controlling whether fields, methods, and constructors are registered; the defaults depend on what you need, so check the annotation for your Quarkus version rather than assuming. For configuration you would rather keep out of code, GraalVM also reads JSON config files under META-INF/native-image/ on the classpath, and Quarkus merges its generated configuration with yours.
Measure the trade instead of assuming it
Run these from the project root with the Maven wrapper. No elevated permissions are needed; the container build requires a working container runtime and permission to pull images.
./mvnw package # JVM mode
./mvnw package -Dnative # native build; needs GraalVM or Mandrel installed
./mvnw package -Dnative -Dquarkus.native.container-build=true # builder container instead
Then compare the two artifacts. Replace the artifact name and version with your own:
java -jar target/quarkus-app/quarkus-run.jar
./target/pricing-service-1.0.0-SNAPSHOT-runner
Measure startup with time and peak memory with /usr/bin/time -v on Linux (look at Maximum resident set size). Compare cold start to cold start, repeat a few times, and record the spread — a single run tells you very little. Expect the native binary to start faster and hold less memory; the size of the gap is what determines whether the trade is worth it for your workload.
To catch reflection gaps before production, run an integration test against the built artifact. In Quarkus 2.x and later, @QuarkusIntegrationTest is the supported annotation; @NativeImageTest is deprecated. An integration test against a native build exercises the real binary, which is the only place a missing registration reliably shows up.
Limitations to accept up front
- Build time. Native builds are substantially slower than JVM builds and can need several gigabytes of memory. Keep them in CI, not in the inner development loop; use
./mvnw quarkus:devon the JVM while writing code. - Tooling. Debugging and profiling a native binary is narrower than on the JVM.
- Library support. Extensions vary in native compatibility, and some libraries are JVM-only by design.
- Silent gaps. A missing registration may not fail the build. It fails when the code path runs.
An actionable next step
Pick one service where startup latency or idle memory actually matters. Add a native build to CI, run your integration tests against the binary, and measure startup and peak RSS alongside the JVM build. If the numbers justify it, keep native for that service and leave the rest on the JVM — Quarkus does not require an all-or-nothing choice, and a mixed fleet is a normal outcome.
These details reflect Quarkus 3.x behavior with a GraalVM or Mandrel toolchain. Native-image tooling and extension compatibility change between releases, so confirm the flags and annotation attributes against the documentation for the version you are running before rolling this out.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.