I remember the first time I deployed a Spring Boot application to a Kubernetes cluster with a strict pod startup budget. The JVM took over fifteen seconds to warm up, and each pod consumed 300 MB of heap before serving a single request. My manager looked at the cloud bill and asked a simple question I couldn’t answer: “Can’t we make it start faster?” That question led me down a path to GraalVM Native Images and Spring Boot 3’s Ahead-of-Time (AOT) processing. After building my first native executable, I saw startup times drop below 80 milliseconds and memory usage fall to 40 MB. This article is the guide I wish I had back then.
Have you ever waited over a minute for a Java app to become ready in a production environment? That delay isn’t a hardware problem. It’s caused by the JVM’s Just-In-Time (JIT) compiler interpreting bytecode at startup, profiling methods, and then compiling hot paths into machine code at runtime. The result is great peak throughput, but terrible startup latency and a large memory footprint from the JIT compiler infrastructure and code caches. GraalVM Native Image escapes this trade-off by shifting the compilation work to build time. Instead of running on a JVM, your application becomes a single, statically-linked executable that starts instantly and uses only the memory it actually needs.
Spring Boot 3 changed everything with its first-class AOT processing engine. The framework now runs an extra phase during your build that loads your ApplicationContext, inspects every bean definition, and generates source code and reachability metadata. This metadata tells GraalVM exactly which classes, methods, fields, and resources must be preserved in the native binary. Without this step, Spring’s dynamic nature — think reflection, proxies, and classpath scanning — would break under GraalVM’s closed-world assumption. The generated files live in target/spring-aot/main/sources if you use Maven. I recommend opening one of those files after your first build; seeing a factory method for each @Bean you defined makes the magic feel tangible.
Let’s set up a real project. I assume you have GraalVM JDK 21 installed and the native-image tool available. Begin with a Spring Boot 3.2+ application that includes spring-boot-starter-web and spring-boot-starter-data-jpa using an H2 in-memory database. I also add spring-boot-starter-validation and spring-boot-starter-actuator to cover common patterns. You can scaffold this with Spring Initializr.
Your Maven pom.xml should include the spring-boot-maven-plugin with the native profile. GraalVM provides a Maven plugin that does the heavy lifting. Here is the minimal setup:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
To trigger the AOT processing and native build, run:
mvn -Pnative native:compile
The -Pnative profile activates the Spring AOT plugin and the GraalVM plugin. After a few minutes, you will have an executable in target/demo.
Now write a simple REST controller to see the difference:
package com.example.demo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class GreetingController {
@GetMapping("/hello")
public String hello() {
return "Hello from native Spring Boot 3!";
}
}
Build the native image and run it:
./target/demo
# Output: Started DemoApplication in 0.082 seconds (JVM running for 0.089)
Compare that to the JVM run: mvn spring-boot:run gives you a startup time around 3–4 seconds. The difference is not small. Memory consumption drops even more — from 200 MB on the JVM to around 40 MB in the native executable. That is the kind of number that makes cloud architects smile.
But what happens when your application uses reflection or reads resource files? GraalVM cannot see those at build time by default. For example, a Hibernate entity with a private field accessed via reflection will cause a NullPointerException at runtime because the field was pruned. Spring Boot 3’s AOT engine handles many common cases automatically, but you will inevitably encounter a missing hint. That is where RuntimeHints come in.
Write a class that implements RuntimeHintsRegistrar:
package com.example.demo;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
public class MyRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// Hibernate entity reflection
hints.reflection().registerType(User.class, builder -> builder.withMembers());
// Resource on classpath
hints.resources().registerPattern("data/*.sql");
}
}
Then register it in your configuration:
@Configuration
@ImportRuntimeHints(MyRuntimeHints.class)
public class MyConfig {
}
If you forget to register a hint, the build succeeds but the executable fails at runtime with an error like java.lang.InstantiationException. The GraalVM documentation calls these “missed reachability errors.” My personal debugging rule is to run the native image with -H:+ReportExceptionStackTraces during development. It gives you a full stack trace instead of a cryptic exit code.
Testing native images is also straightforward. Spring Boot provides @NativeImageTest to run tests inside the native binary:
@NativeImageTest
class GreetingControllerTest {
@Autowired
TestRestTemplate restTemplate;
@Test
void helloReturnsExpectedMessage() {
String body = restTemplate.getForObject("/hello", String.class);
assertThat(body).isEqualTo("Hello from native Spring Boot 3!");
}
}
Run it with mvn -Pnative test and watch your normal JUnit 5 tests execute in the same binary that will run in production. This catches configuration errors before deployment.
Containerizing the native image is where the real savings shine. You can use Spring Boot’s layered JAR with Buildpacks (mvn spring-boot:build-image) to produce a Docker image, but I prefer a distroless Dockerfile for the smallest possible footprint:
FROM gcr.io/distroless/base-debian11:nonroot
COPY target/demo /app/demo
EXPOSE 8080
CMD ["/app/demo"]
The resulting image is under 100 MB, and it contains no shell, no package manager, and no JVM. This is the container you want in a zero-trust environment.
Ready for a confession? My first three native builds failed. One because I used Jackson polymorphic deserialization without registering subtypes. Another because I had a @Value annotation pointing to a property file that got pruned. The third because my Quartz scheduler used CGLIB proxies. Each failure taught me exactly where the closed-world assumption breaks down. Once I understood that every dynamic call must be enumerated at build time, I started writing RuntimeHints as part of my feature work, not after the fact.
The true benefit of native images goes beyond startup speed. In serverless environments, you pay for every millisecond of cold start. A native Spring Boot function on AWS Lambda starts in 0.2 seconds versus 6 seconds for a JVM version. That cost difference compounds across thousands of invocations. In microservice architectures, you can run more instances on the same cluster, reducing your node count and saving infrastructure money.
Now the question I always ask myself: is this the right tool for every application? Not always. If your service runs long, stable workloads and you need the highest possible throughput under sustained load, a JIT-compiled JVM can still outperform a native image on peak operations. Native images also have slower memory allocation compared to the JVM’s advanced garbage collectors. But for cloud-native services where fast startup, low memory, and instant scaling matter more than absolute peak throughput, GraalVM native images with Spring Boot 3 are a genuine game-changer.
If you found this guide helpful, please like this article, share it with a colleague who has stared at a long Spring Boot startup log, and leave a comment describing your own experience with native images. I read every comment and I often use your questions to craft future tutorials.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva