Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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):

PhaseTimeWhy
JVM cold start~1.5 srt.jar loading, JIT warmup
SpringApplication setup~0.3 sSpringApplication constructor, environment
Classpath scan + auto-configuration~0.7 sAutoConfiguration.imports loading, @Conditional evaluation
Bean instantiation + autowiring~0.4 sReflection-based BeanWrapper, @Autowired
Tomcat embed start~0.3 sConnector, 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:

  1. 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 emit reflect-config.json / resource-config.json for the next phase.
  2. Native image (optional) — GraalVM’s native-image tool 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:

  • $proxy classes for @Transactional / @Validated AOP, eliminating the runtime CGLIB or JDK dynamic proxies that Spring uses.
  • ReflectConfig and ResourceConfig JSON for GraalVM native image builds.
  • AnnotationMetadata binary 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.json throws ClassNotFoundException at 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=http etc.).
  • 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-time snapshots 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):

FrameworkModeStartup (to first request)RSS at rest
Spring Boot 3.xJVM~3.0 s~250 MB
Spring Boot 3.x + Spring NativeNative (GraalVM)~150 ms~80 MB
Quarkus 3.xJVM~1.0 s~150 MB
Quarkus 3.xNative~30 ms~25 MB
Micronaut 4.xJVM~1.2 s~150 MB
Micronaut 4.xNative~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 — /metrics in Prometheus format.
  • MicroProfile Fault Tolerance — @Retry, @CircuitBreaker, @Timeout, @Bulkhead, @Fallback annotations.
  • MicroProfile JWT — JWT-based auth with @JsonWebToken injection.
  • MicroProfile OpenAPI — auto-generates /openapi from JAX-RS endpoints.
  • MicroProfile Rest Client — type-safe HTTP clients: @RegisterRestClient on 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

ConcernSpring BootQuarkusMicronaut
DI mechanismRuntime reflectionBuild-time augmentation, runtime BeanContainerCompile-time processor, runtime BeanContext
Programming modelSpring annotationsCDI + JAX-RS + MicroProfileSpring-like annotations, mostly compatible
Reactive runtimeOptional (WebFlux/Reactor)Default for HTTP (Vert.x)Optional (micronaut-http-server-netty)
Native imageSpring Native, maturingFirst-class since 1.0First-class since 1.0
Startup (native)~150 ms (still maturing)~30 ms~100 ms
Memory (native)~80 MB~25 MB~50 MB
Hot reloadDevtools (~1 s)Live reload (~200 ms)Application recompile
Ecosystem depthMassiveGrowing fast, Red Hat backedSmaller, OCI backed
Spec alignmentSpring itselfMicroProfile + CDI + JakartaMix (some Spring-like idioms)
Best fitLong-running monoliths, enterprise integrationServerless, scale-to-zero, Kubernetes-nativeMicroservices, 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

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/