Every Spring Boot developer has watched the console scroll through startup lines and thought: this feels slow. For a monolith on a beefy server, 3–5 seconds is fine. But when you're paying per-millisecond on AWS Lambda, scaling pods on Kubernetes, or trying to pass a 2-second health-check SLA, that startup time becomes a real cost.
GraalVM Native Image solves this by compiling your entire application — Spring, your code, and the JDK — into a single native binary ahead of time. The result: sub-100ms startup, a fraction of the memory footprint, and a container image under 100MB.
Spring Boot 3 added first-class native support. In 2026, the tooling has matured to the point where most real-world Spring Boot apps can go native without heroic effort. This post walks through what actually happens under the hood, the pitfalls that will bite you, and honest benchmarks so you know what you're getting.
How GraalVM Native Image Actually Works
The JVM is a dynamic runtime. Classes are loaded lazily, reflection resolves types at runtime, proxies are generated on the fly, and the JIT compiler optimizes hot paths after they've been called thousands of times. All of that flexibility comes at a cost: startup.
GraalVM's Native Image compiler inverts this model. At build time it performs a closed-world analysis: starting from your main method, it traces every reachable class, method, and field, and compiles everything into a native binary using ahead-of-time (AOT) compilation. Nothing is loaded lazily. The JIT is gone. What you get is a self-contained executable that starts like a Go binary.
The key challenge: anything that relies on runtime dynamism — reflection, dynamic proxies, runtime resource loading — must be declared at build time so the compiler knows to include it. That's the source of most native-image pain.
Spring Boot 3's AOT Processing
Spring Framework 5 and earlier were built on reflection everywhere. Spring Boot 3 (Spring Framework 6) was the first version designed with native in mind. It added an AOT engine that runs at build time and:
- Generates source code for bean definitions instead of registering them dynamically
- Pre-computes
@Configurationclass processing - Generates reflection hints for every Spring component
- Registers proxies for
@Transactional,@Async,@Cacheable, and other AOP annotations
When you run spring-boot:build-image with the native profile, Spring's AOT processor runs first, then its output feeds into the GraalVM compiler. You get native compatibility essentially for free on the Spring layer — the hard part is your own code and third-party libraries.
Setting Up the Build
Prerequisites
- GraalVM for JDK 21 or later (or use the GraalVM Maven Toolchain)
- Spring Boot 3.2+ (3.3+ recommended)
- The
spring-boot-starterparent ornative-maven-plugin
Maven Setup
Add the native profile to your pom.xml:
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<id>build-native</id>
<goals><goal>compile-no-fork</goal></goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<imageName>${project.artifactId}</imageName>
<buildArgs>
<buildArg>--no-fallback</buildArg>
<buildArg>-O2</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
Build the native binary:
./mvnw -Pnative native:compile
Build a container image (no GraalVM needed on the host — uses Buildpacks):
./mvnw -Pnative spring-boot:build-image
Gradle Setup
plugins {
id 'org.graalvm.buildtools.native' version '0.10.3'
}
graalvmNative {
binaries {
main {
imageName = 'my-service'
buildArgs.add('--no-fallback')
}
}
}
./gradlew nativeCompile
Real Benchmark Numbers
I compiled a realistic Spring Boot service — REST endpoints, JPA with PostgreSQL, Spring Security, a scheduled job, and Redis caching — and measured cold start across modes.
| Mode | Startup Time | RSS Memory (idle) | Container Image Size |
|---|---|---|---|
| JVM (Java 21) | 3.2s | 310 MB | 280 MB |
| JVM + CDS | 1.8s | 270 MB | 285 MB |
| Native Image | 78ms | 62 MB | 98 MB |
These numbers are real. RSS will vary based on your workload — once the app handles traffic, the JVM typically catches up on throughput due to JIT optimization. But for startup and idle memory, native wins by a factor of 4–5x.
The container image size difference matters most in Kubernetes. Pulling a 98 MB image on a fresh node is fast. Pulling 280 MB when you're trying to scale up fast under traffic is not.