Quarkus and Micronaut: Build-Time-Native Java Frameworks
Overview
Spring Boot’s thesis is that runtime DI gives you flexibility: beans can be swapped at startup, conditional logic runs at boot time, configuration drives wiring. Quarkus and Micronaut both argue that for cloud-native deployments — serverless, microservices, scale-to-zero — that flexibility is overhead you pay every time the JVM starts. They both move work to the build/compile time, both ship GraalVM native images as a first-class target, and both target Kubernetes as the deployment substrate.
The two differ in mechanism. Quarkus is Red Hat’s framework; it uses build-time “augmentation” — a phase where Quarkus extensions process application classes and generate bytecode for fast startup, and at native build time the GraalVM native-image tool AOT-compiles everything. Micronaut is Object Computing Inc.’s framework; it uses a JSR-269 annotation processor at compile time to compute the DI graph and generate bytecode for each @Inject site, eliminating reflection at the source. The end goals converge; the internals differ.
Related: Java Overview, Spring Boot Internals, JVM Internals, JVM JIT, Reactive Programming, Java Concurrency Deep Dive
The Spring Boot Tax
A trivial Spring Boot web service with one endpoint, cold-start numbers (measured on a 4-core cloud instance, OpenJDK 21):
| Phase | Time | Why |
|---|---|---|
| JVM cold start | ~1.5 s | rt.jar loading, JIT warmup |
SpringApplication setup | ~0.3 s | SpringApplication constructor, environment |
| Classpath scan + auto-configuration | ~0.7 s | AutoConfiguration.imports loading, @Conditional evaluation |
| Bean instantiation + autowiring | ~0.4 s | Reflection-based BeanWrapper, @Autowired |
| Tomcat embed start | ~0.3 s | Connector, servlet context |
| Total | ~3.2 s | |
| RSS at rest | ~250 MB |
For a long-running monolith this is irrelevant. For a Lambda that AWS keeps cold between bursts, or a scale-to-zero microservice that gets woken 100×/minute during peak, this is the dominant latency. See Spring Boot Internals for the runtime-DI baseline these frameworks optimize away.
Quarkus Architecture
┌──────────────────────────────────────────────────────────────────────┐
│ BUILD TIME │
│ │
│ ┌──────────────────┐ │
│ │ Application │ ←── user's @ApplicationScoped, @Path, etc. │
│ │ classes │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ ┌───────────────────────────────┐ │
│ │ Jandex index │ ───► │ Quarkus build steps │ │
│ │ (class metadata) │ │ (one per extension: resteasy, │ │
│ │ generated by │ │ hibernate-orm, rest-client, │ │
│ │ quarkus-jandex │ │ narayana, smallrye-jwt, ...) │ │
│ │ plugin │ │ │ │
│ └──────────────────┘ │ Each step: │ │
│ │ - discovers @Annotations │ │
│ │ - generates bytecode │ │
│ │ for runtime wiring │ │
│ │ - emits GraalVM substitution │ │
│ │ metadata │ │
│ └───────────────┬───────────────┘ │
│ │ │
│ ┌─────────────────────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────┐ │
│ │ Augmented application classes │ │
│ │ + generated bean containers │ │
│ │ + reflection/serialization config │ │
│ │ for GraalVM (reflect-config.json)│ │
│ └────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ NATIVE BUILD (optional) │
│ │
│ native-image --no-fallback --report-unsupported-elements-at-runtime│
│ -H:ReflectionConfigurationFiles=... │
│ -H:ResourceConfigurationFiles=... │
│ -H:+JNI -H:+AllowIncompleteClasspath │
│ --initialize-at-build-time=... │
│ --initialize-at-run-time=... │
│ │
│ → single static binary (the "Substrate VM") │
│ → no JIT, no classloader, no interpreter │
│ → ~30 MB ELF executable │
└──────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────┐
│ ./runner │ ← ~30 ms to first request
│ (ELF binary) │ ~25 MB RSS at rest
└────────────────┘
Two build phases:
- Augmentation (always runs, both JVM and native mode) — Quarkus extensions (one per integration:
quarkus-resteasy,quarkus-hibernate-orm, etc.) read the application via the Jandex class index, generate helper classes, and emitreflect-config.json/resource-config.jsonfor the next phase. - Native image (optional) — GraalVM’s
native-imagetool performs whole-program AOT analysis, statically resolves every reachable call, runs class initializers marked--initialize-at-build-time, and produces a single executable containing the Substrate VM (a stripped-down JVM with no JIT, no interpreter, no classloader).
The runtime, in JVM mode, runs the augmented bytecode; in native mode, runs the compiled binary. Same code, different deployment.
Build-time initialization
The most important Quarkus power is --initialize-at-build-time=<pkg>: the GraalVM native image runs <clinit> at build time, then snapshots the heap state into the binary. At runtime, the state is mmap’d read-only and the application starts with singletons already built. Quarkus pushes as much as possible here:
- The CDI bean container — pre-resolved.
- The JAX-RS routing table — pre-built.
- Hibernate’s
EntityManagerFactory— partially, where it doesn’t depend on runtime config. - Logging configuration — pre-parsed.
The remaining runtime init — the bits that need runtime config (port, DB URL) — is left to --initialize-at-run-time=<pkg>. The split is per-class, decided by the Quarkus extension author.
Micronaut Architecture
Micronaut’s approach is simpler in concept and harder to author: a JSR-269 annotation processor runs at compile time and emits bytecode for every @Inject, @Singleton, @ConfigurationProperties, and route.
// Source:
@Singleton
public class UserService {
private final UserRepository repo;
@Inject
public UserService(UserRepository repo) { this.repo = repo; }
public User find(Long id) { return repo.findById(id); }
}
After Micronaut’s processor runs, the compiled class has a companion $UserServiceDefinition class generated next to it:
// Generated (simplified):
public class UserService$Definition implements BeanDefinition<UserService> {
public UserService instantiate(BeanContext ctx) {
UserRepository repo = ctx.getBean(UserRepository.class);
return new UserService(repo); // direct constructor call, no reflection
}
// ... + methods for @PostConstruct, @PreDestroy, AOP interceptors ...
}
The runtime BeanContext (in micronaut-context) looks up BeanDefinitions by scanning an index generated by the same processor and instantiates beans via the generated instantiate methods. No reflection, no Class.forName, no Constructor.newInstance. The DI graph is closed at compile time; at runtime, only the order of bean creation and the resolution of values from config remain.
The processor is micronaut-inject-java, the annotation processor. It also generates:
$proxyclasses for@Transactional/@ValidatedAOP, eliminating the runtime CGLIB or JDK dynamic proxies that Spring uses.ReflectConfigandResourceConfigJSON for GraalVM native image builds.AnnotationMetadatabinary lookups for fast introspection.
GraalVM Native Image
Both frameworks depend on GraalVM’s native-image tool for native builds. The tool’s contract:
- Closed-world assumption: every class, method, and field that can be reached at runtime must be reachable at build time. Reflection on a class that wasn’t registered via
reflect-config.jsonthrowsClassNotFoundExceptionat runtime. - No dynamic class loading:
Class.forName("X")works only if X is in the configuration. New classes loaded from network sources — common in plugin systems — do not work without extra setup (--enable-url-protocols=httpetc.). - No JIT: the binary is statically compiled. Long-running applications lose the JIT’s profile-guided optimizations, so native mode is fastest for short-lived or scale-to-zero workloads. A monolith that runs for a month and serves millions of requests is typically faster in JVM mode.
- No finalization:
Object.finalize()is ignored (deprecated for removal in Java 25, irrelevant here anyway). - Heap snapshotting:
--initialize-at-build-timesnapshots the post-<clinit>heap into the binary. This is what gives sub-100ms starts.
The configuration is the painful part — a normal Spring Boot app reflects on thousands of classes (Hibernate proxies, Jackson serializers, Bean Validation constraints). Quarkus and Micronaut generate this config at build time; Spring users must use the native-image agent, run the app once to trace every reflective call, and dump reflect-config.json themselves (Spring Native is being built to do this automatically, but is still maturing).
Startup and Memory Comparison
Typical numbers (small HTTP service, one GET endpoint returning a JSON object, on a 4-core/8 GB cloud VM, OpenJDK 21 for the JVM numbers, GraalVM 23 for native):
| Framework | Mode | Startup (to first request) | RSS at rest |
|---|---|---|---|
| Spring Boot 3.x | JVM | ~3.0 s | ~250 MB |
| Spring Boot 3.x + Spring Native | Native (GraalVM) | ~150 ms | ~80 MB |
| Quarkus 3.x | JVM | ~1.0 s | ~150 MB |
| Quarkus 3.x | Native | ~30 ms | ~25 MB |
| Micronaut 4.x | JVM | ~1.2 s | ~150 MB |
| Micronaut 4.x | Native | ~100 ms | ~50 MB |
The Quarkus-native number (~30 ms) is the marketing gold standard. The Micronaut number is slower because Micronaut’s generated factory classes are more numerous (one per bean, vs Quarkus’s consolidated BeanContainer), and Micronaut’s processor does slightly more per-class work. Both are an order of magnitude faster than Spring Boot on JVM.
A caveat: the Quarkus native number is benchmarked on a service that does almost nothing. A service that connects to Postgres on startup pays the JDBC connection handshake (~50-200 ms) on every cold start; this dominates. Native is not magic.
MicroProfile
Eclipse MicroProfile is a specification for microservices, started in 2016 by IBM, Red Hat, and others as a counterweight to Jakarta EE’s heavyweight monolith patterns. Its APIs:
- MicroProfile Config (
org.eclipse.microprofile.config) — typed config injection, twelve-factor friendly.@ConfigProperty(name = "app.timeout", defaultValue = "30") Long timeout; - MicroProfile Health —
/health/live,/health/ready,/health/started. - MicroProfile Metrics —
/metricsin Prometheus format. - MicroProfile Fault Tolerance —
@Retry,@CircuitBreaker,@Timeout,@Bulkhead,@Fallbackannotations. - MicroProfile JWT — JWT-based auth with
@JsonWebTokeninjection. - MicroProfile OpenAPI — auto-generates
/openapifrom JAX-RS endpoints. - MicroProfile Rest Client — type-safe HTTP clients:
@RegisterRestClienton an interface, injected.
Quarkus implements MicroProfile through SmallRye (Red Hat’s reference implementation). Micronaut implements a subset directly (micronaut-microprofile-* modules). Spring Boot does not — Spring’s equivalents are Spring-native (@ConfigurationProperties for config; Spring Security for JWT; Spring retries for fault tolerance).
The practical consequence: if you write to MicroProfile APIs, your code is portable between Quarkus, OpenLiberty, WildFly, Payara, and (mostly) Micronaut. If you write to Spring, it is not portable at all — Spring is its own de-facto specification.
Kubernetes-Native Deployment
Both Quarkus and Micronaut ship extensions that generate Kubernetes manifests from build configuration:
Quarkus:
<!-- pom.xml -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-kubernetes</artifactId>
</dependency>
Running mvn package generates kubernetes.yml (or openshift.yml, knative.yml if those extensions are present) with deployment, service, and readiness probes wired to the MicroProfile health endpoints:
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo
spec:
template:
spec:
containers:
- name: demo
image: quay.io/.../demo:1.0.0
livenessProbe:
httpGet:
path: /q/health/live
port: 8080
readinessProbe:
httpGet:
path: /q/health/ready
port: 8080
Micronaut has the equivalent with micronaut-kubernetes.
For Knative scale-to-zero, the native-image startup is what makes the whole thing economically viable — a service that scales from 0 to 1 in 30 ms is a service you can leave at zero overnight.
Comparison Summary
| Concern | Spring Boot | Quarkus | Micronaut |
|---|---|---|---|
| DI mechanism | Runtime reflection | Build-time augmentation, runtime BeanContainer | Compile-time processor, runtime BeanContext |
| Programming model | Spring annotations | CDI + JAX-RS + MicroProfile | Spring-like annotations, mostly compatible |
| Reactive runtime | Optional (WebFlux/Reactor) | Default for HTTP (Vert.x) | Optional (micronaut-http-server-netty) |
| Native image | Spring Native, maturing | First-class since 1.0 | First-class since 1.0 |
| Startup (native) | ~150 ms (still maturing) | ~30 ms | ~100 ms |
| Memory (native) | ~80 MB | ~25 MB | ~50 MB |
| Hot reload | Devtools (~1 s) | Live reload (~200 ms) | Application recompile |
| Ecosystem depth | Massive | Growing fast, Red Hat backed | Smaller, OCI backed |
| Spec alignment | Spring itself | MicroProfile + CDI + Jakarta | Mix (some Spring-like idioms) |
| Best fit | Long-running monoliths, enterprise integration | Serverless, scale-to-zero, Kubernetes-native | Microservices, native, Spring-like ergonomics |
When to Choose What
- Existing Spring team, long-running services: stay with Spring Boot. The migration cost is real; the runtime tax on a long-running service is amortized to zero.
- Greenfield microservice on Kubernetes, native image, scale-to-zero: Quarkus or Micronaut. Native startup dominates; the choice between them is mostly ecosystem fit (CDI/JAX-RS vs Spring-like annotations) and whether Vert.x reactive is your default (Quarkus) or you want blocking/servlet ergonomics (Micronaut).
- Function-as-a-Service (AWS Lambda, Google Cloud Functions): Quarkus’s native binary at 30 ms is the clear winner for cold-start-sensitive paths.
- Heavy use of reactive code, full reactive stack: Quarkus ships Vert.x as the default HTTP layer — most idiomatic if you already embrace reactive.
Interview Questions
Q: Why is Quarkus startup faster than Spring Boot?
Two reasons. (1) Quarkus runs the auto-configuration equivalent at build time (augmentation), generating bytecode for fast runtime wiring, while Spring Boot does the same work at runtime via reflection. (2) In native mode, Quarkus pushes class initialization to build time via GraalVM’s --initialize-at-build-time, snapshotting heap state into the binary so the runtime doesn’t reconstruct singletons.
Q: What is GraalVM native image and what are its limitations?
A whole-program AOT compiler producing a static binary that runs without a JVM. Limitations: closed-world assumption (no dynamic class loading), no JIT (so long-running CPU-bound work is slower), no finalize(), reflective access must be pre-registered via reflect-config.json (which Quarkus/Micronaut generate, Spring users must run the agent).
Q: What is the difference between Quarkus’s and Micronaut’s DI mechanism?
Quarkus uses build-time “augmentation” — a build step reads the Jandex class index and generates runtime helper classes; runtime is a BeanContainer. Micronaut uses a JSR-269 annotation processor at compile time, generating one BeanDefinition per bean; runtime is a BeanContext that calls those generated factories directly. Both eliminate runtime reflection; Quarkus centralizes in one container, Micronaut decentralizes one factory per bean.
Q: Why does Quarkus native start in ~30 ms while Micronaut native takes ~100 ms?
Because Quarkus consolidates wiring into one generated BeanContainer initialized at build time, and aggressively uses --initialize-at-build-time to snapshot singletons. Micronaut generates one BeanDefinition per bean; instantiation still happens at runtime via calls to those factories. The difference is small in absolute terms (both are well under a second) but Quarkus’s design is more amenable to build-time snapshotting.
Q: What is MicroProfile and why does it matter here? A specification for microservices started in 2016 (IBM, Red Hat, others) including Config, Health, Metrics, Fault Tolerance, JWT, OpenAPI, Rest Client. Quarkus implements it via SmallRye; Micronaut implements a subset. The value is portability — code written to MP APIs runs on multiple runtimes.
Q: Why is native mode not always faster? Because GraalVM native has no JIT. For a serverless Lambda that runs for 100 ms, native is dramatically faster (no JIT warmup, no JVM cold start). For a monolith that runs for 30 days and serves 100k req/s, the JIT’s profile-guided inlining and polymorphic call site optimizations win, and native mode is typically slower in steady state.
Q: What does --initialize-at-build-time do and why is it powerful?
It tells native-image to run the static initializer (<clinit>) of the named class during the build, then snapshot the resulting heap state into the binary. At runtime, the state is mmap’d read-only and the class is “already initialized”. For Quarkus’s bean container, this means the container is fully built at build time and at runtime the cost is a single mmap.
Cross-References
- Java Overview
- Spring Boot Internals — the runtime-DI baseline these frameworks optimize away
- JVM Internals — what GraalVM Substrate VM omits
- JVM JIT — what native mode omits at runtime
- Reactive Programming — Vert.x (Quarkus default HTTP layer) and Netty (Micronaut)
- Java Concurrency Deep Dive — Vert.x event loops vs virtual threads
- Virtual Threads — an alternative path to “high-concurrency without reactive”
References
- Quarkus Documentation — https://quarkus.io/guides/
- Quarkus — Architecture — https://quarkus.io/guides/quarkus-runtime-guide
- Micronaut Documentation — https://docs.micronaut.io/
- Micronaut — AOT compilation — https://docs.micronaut.io/latest/guide/#aot
- GraalVM Native Image Reference Manual — https://www.graalvm.org/reference-manual/native-image/
- GraalVM — Reachability Metadata — https://www.graalvm.org/reference-manual/native-image/metadata/
- Thoughts on Java — Quarkus vs Spring Boot — https://www.thoughtsonjava.com/quarkus-vs-spring-boot/
- Red Hat — Quarkus vs Spring Boot — https://www.redhat.com/en/topics/cloud-native-apps/
- MicroProfile Specifications — https://microprofile.io/specs/