06-production-cloud-native

GraalVM Native Image: What You Gain and What You Give Up

Sub-100ms startup and much less memory, in exchange for the closed-world assumption, long builds and reflection hints — and when that trade pays.

October 9, 2026
spring-bootgraalvmnative-imageaotstartup-timeserverlesscloud-native

The Numbers

A native image is your application compiled ahead of time into a standalone executable — no JVM at runtime.

JVMNative image
Startup2–6s~50–100ms
Memory (idle)250–400 MB~60–120 MB
Image size~180 MB + app~80 MB total
Peak throughputHigher (JIT)Slightly lower
Build time~30s3–10 minutes
Runtime reflectionUnrestrictedMust be declared

Treat those figures as indicative — they vary with application size and configuration. The shape is what matters: dramatically faster startup and lower memory, slightly lower peak throughput, and a much slower, stricter build.

The Closed-World Assumption

Everything hard about native images follows from one design decision.

GraalVM performs static analysis of your entire program to find all reachable code, then compiles only that. Anything it cannot see is not included. At runtime there is no classloader and no bytecode, so nothing can be added later.

This is what makes the binary small and fast. It is also what breaks the JVM's most dynamic features:

FeatureNative image
ReflectionOnly on declared classes/members
Dynamic proxiesOnly declared interface combinations
Resource loadingOnly declared resources
SerialisationMust be declared
Classpath scanningAt build time, not runtime
Bytecode generation at runtimeNot possible

Spring uses almost all of these. Component scanning, auto-configuration, @Transactional proxies, Jackson's reflective binding, JPA's entity enhancement — all dynamic.

Spring's AOT Processing

Spring solves this by doing its dynamic work at build time. Ahead-of-time processing runs the context creation during the build and emits generated code describing the result:

  • Bean definitions become generated Java rather than runtime scanning
  • Auto-configuration conditions are evaluated at build time and the outcome fixed
  • Proxy classes are generated as real classes
  • Reflection, resource and serialisation hints are written out for GraalVM

The consequence worth internalising: the context is fixed at build time. Which has a direct effect on how you configure the application.

🚨

Profiles that change which beans exist cannot be switched at runtime. AOT evaluates @Profile and @Conditional during the build, so the bean set is baked into the binary. A @Profile("prod") bean is either compiled in or not.

Property values still come from the environment as normal — ${DATABASE_URL} works exactly as the configuration guide describes. It is conditional bean existence that freezes. If you rely on profiles to vary structure between environments, native images require either a build per profile (-Dspring.profiles.active=prod at build time) or restructuring so the difference is properties rather than beans.

Building One

groovy
plugins {
    id 'java'
    id 'org.springframework.boot' version '4.0.0'
    id 'io.spring.dependency-management' version '1.1.7'
    id 'org.graalvm.buildtools.native' version '0.10.6'
}
bash
./gradlew nativeCompile      # local executable, needs a GraalVM JDK installed
./gradlew bootBuildImage     # native container image, needs only Docker

The second form is usually what you want in CI — buildpacks supply the GraalVM toolchain, so the build host needs no special setup. For bootBuildImage to produce a native image rather than a JVM one, point it at the native builder:

groovy
tasks.named('bootBuildImage') {
    builder = 'paketobuildpacks/builder-noble-java-tiny:latest'
    environment = ['BP_NATIVE_IMAGE': 'true']
}

Spring Boot 4 ships AOT support as standard, and the common starters — web, data JPA, security, actuator — come with reachability metadata. A straightforward application frequently builds with no hints at all.

When Hints Are Needed

Library code using reflection that Spring's metadata doesn't cover will fail — at runtime, not build time:

text
com.oracle.svm.core.jdk.UnsupportedFeatureError: Proxy class defined by interfaces [...]
ClassNotFoundException: com.example.SomeClass

Supply hints:

java
@Configuration
@RegisterReflectionForBinding({ ExternalApiResponse.class, LegacyPayload.class })
public class NativeHintsConfig { }

Or programmatically, for the cases the annotation can't express:

java
public class ShopRuntimeHints implements RuntimeHintsRegistrar {
 
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        hints.reflection().registerType(LegacyPayload.class,
                MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                MemberCategory.DECLARED_FIELDS);
        hints.resources().registerPattern("templates/*.ftl");
        hints.serialization().registerType(SessionToken.class);
        hints.proxies().registerJdkProxy(LegacyGateway.class);
    }
}
java
@ImportRuntimeHints(ShopRuntimeHints.class)
@Configuration
public class NativeHintsConfig { }

@RegisterReflectionForBinding covers the common case: a class reflectively bound by Jackson that no controller signature mentions, so static analysis never sees it.

⚠️

Missing hints fail at runtime, often on a code path that isn't exercised at startup. A native image can start perfectly and fail when an unusual request reaches that class. The mitigation is non-negotiable: run your full integration test suite against the native binary.

bash
./gradlew nativeTest                # runs tests in native mode

Startup success is not evidence of correctness in native mode, and this is the single most important practice when adopting it.

Where the Trade Pays

Worth it:

  • Serverless / scale-to-zero — AWS Lambda, Cloud Run, Knative. Cold start is the dominant cost and 100ms versus 4 seconds changes what's viable.
  • CLI tools — a 3-second JVM startup is intolerable for a command run constantly.
  • Very high replica counts — 60 MB versus 300 MB across 500 pods is real money.
  • Rapid autoscaling — a service scaling on bursty traffic that needs capacity in a second.

Not worth it:

  • A long-running service with steady traffic — it starts once a day. You've traded peak throughput and build simplicity for a benefit you never collect.
  • CPU-bound workloads — the JIT's profile-guided optimisation genuinely wins at sustained load.
  • Applications with heavy dynamic behaviour — extensive reflection, runtime bytecode generation, or plugin loading.
  • Teams without native CI capacity — a 10-minute build per commit changes how a team works.
✅

Try Spring's AOT processing without native compilation first. ./gradlew processAot (or bootRun with -Dspring.aot.enabled=true) gives you the generated bean definitions on a normal JVM — typically 20–40% faster startup, lower memory, and none of the closed-world constraints. For many services that is most of the benefit at a fraction of the cost, and it's reversible.

Other Things That Change

Peak throughput is slightly lower. The JIT compiles hot paths using runtime profiles; ahead-of-time compilation cannot. Profile-guided optimisation narrows the gap but needs a representative training run, and it's a paid GraalVM feature.

Observability tooling differs. JFR and JMX support is limited, heap dumps work differently, and some agents don't attach. The metrics and tracing from phase 4 work, but check anything you depend on operationally before committing.

Debugging is harder. No jdb, no attaching a profiler the usual way, and native debug symbols are a different workflow.

Build time reshapes your pipeline. 3–10 minutes per image, usually needing more memory than a JVM build. Many teams build JVM images per commit and native images only for release.

Check yourself

A team compiles to a native image. It starts in 80ms and passes smoke tests. A week later, one endpoint fails in production with a ClassNotFoundException for a DTO used only in that endpoint's response. Why wasn't this caught?

A Reasonable Adoption Path

  1. Enable AOT on the JVM (-Dspring.aot.enabled=true). Measure. Often enough on its own.
  2. Build a native image in CI without deploying it, and run the full test suite in native mode. This surfaces missing hints with no production risk.
  3. Deploy native for a workload that benefits — a scale-to-zero function, a CLI, a high-replica stateless service.
  4. Keep the JVM path working. The same codebase should build both, so you can fall back.

That last point matters. Native compilation is a build configuration, not an architectural commitment — provided you don't let profile-dependent bean structure creep in.

The Mental Model, Restated

  1. ~50–100ms startup and 2–4× less memory, for slightly lower peak throughput and much slower builds.
  2. Everything hard follows from the closed-world assumption — no runtime classloading, so reflection must be declared.
  3. Spring AOT moves dynamic work to build time, which fixes the bean set in the binary.
  4. Property values stay runtime; conditional bean existence does not.
  5. Missing hints fail at runtime on untested paths. Run the whole suite in native mode.
  6. Worth it for serverless, CLIs and high replica counts; not for steady long-running services.
  7. Try JVM AOT first — much of the benefit, none of the constraints.

What's Next

Native images attack startup and memory. The other scalability constraint is concurrency — the one-thread-per-request model that made a pool of 200 the ceiling. The next guide covers virtual threads in Spring Boot 4: what they change, what pinning is, and why enabling them means revisiting every pool you have sized.