Spring Boot Native Images with GraalVM: Why Your App Starts Slow
Problem
My Spring Boot application took 12 seconds to start. In development, that’s annoying. In serverless? That’s a disaster.
AWS Lambda has a 15-minute timeout, but the real killer is cold start latency. Every time my function woke up, users waited. And waited. The JVM needed to load classes, warm up the JIT compiler, and initialize the Spring context.
I tried tuning JVM parameters. I tried lazy initialization. I shaved off maybe 2 seconds. Still too slow.
Then I heard about GraalVM native images. The promise: near-instant startup, lower memory usage. But when I tried to build one, I hit errors I’d never seen before. ClassNotFoundException at runtime. Reflection issues everywhere.
This post documents what I learned: why native images matter, how to build them, and the traps I fell into.
The Root Cause
Traditional JVM applications have a startup problem. Here’s what happens when you run java -jar app.jar:
java -jar app.jar | v+------------------+| JVM Boot | <-- Load JVM, initialize heap+------------------+ | v+------------------+| Class Loading | <-- Load thousands of classes+------------------+ | v+------------------+| Verification | <-- Verify bytecode+------------------+ | v+------------------+| Spring Init | <-- Scan beans, create context+------------------+ | v+------------------+| JIT Warmup | <-- Compile hot code (takes time!)+------------------+ | v Ready to serveEach step takes time. Spring Boot applications typically have thousands of classes. The framework scans for beans, processes annotations, and builds the application context. All of this happens at runtime.
The result? Startup times measured in seconds. Memory footprints in hundreds of megabytes.
The Solution: Native Images
GraalVM native images flip the script. Instead of compiling at runtime, you compile ahead of time (AOT).
Source Code (.java) | v+------------------+| Compile Time || ------------ || 1. AOT Compile | <-- Compile Java to native machine code| 2. Static | <-- Analyze which classes are reachable| Analysis || 3. Dead Code | <-- Remove unused code| Elimination || 4. Heap | <-- Snapshot initial heap state| Snapshot |+------------------+ | vNative Executable | v+------------------+| Runtime || -------- || Instant Start | <-- No class loading, no JIT warmup| Low Memory | <-- Only loaded code in memory+------------------+The benefits are real:
| Metric | JVM | Native Image |
|---|---|---|
| Startup time | 5-15 seconds | 50-200 ms |
| Memory usage | 200-500 MB | 20-80 MB |
| Package size | JAR (50-100 MB) | Binary (30-80 MB) |
But there are trade-offs. Build times are longer (minutes, not seconds). Dynamic features like reflection require explicit configuration. And you lose JIT runtime optimization.
Setting Up the Environment
Before building native images, I needed the right tools.
Step 1: Install GraalVM
On macOS with SDKMAN:
# Install SDKMAN if you don't have itcurl -s "https://get.sdkman.io" | bash
# Install GraalVMsdk install java 21.0.2-graalsdk use java 21.0.2-graal
# Verify installationjava -version# Output should mention "GraalVM"Step 2: Install Native Image Tool
gu install native-image
# Verifynative-image --versionStep 3: Verify Prerequisites
On macOS, you need Xcode command line tools. On Linux, you need gcc, glibc-devel, zlib-devel, and standard build tools.
Building Your First Native Image
I started with a simple Spring Boot application. Nothing fancy—just a REST endpoint.
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.0</version></parent>
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency></dependencies>
<build> <plugins> <plugin> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <id>process-aot</id> <goals> <goal>process-aot</goal> </goals> </execution> </executions> </plugin> </plugins></build>The key elements:
native-maven-plugin: GraalVM’s build toolprocess-aotgoal: Spring’s ahead-of-time processing
Building the native image:
./mvnw -Pnative native:compileThe first build took 3 minutes on my machine. The result was a single executable file in target/ directory.
Running it:
./target/myapp
# The startup was almost instant:# Started Application in 0.089 seconds0.089 seconds. Compared to 12 seconds before. The difference is dramatic.
The First Error: Reflection
My excitement lasted until I added JSON serialization.
@RestControllerpublic class UserController {
@GetMapping("/user") public User getUser() { }}Running the native executable, I got:
Error: java.lang.ClassNotFoundException: com.example.User at java.lang.Class.forName(DynamicHub.java:1059) at com.fasterxml.jackson.databind.util.ClassUtil...What happened? Native images use static analysis at build time. The compiler analyzes which classes are reachable. Classes only accessed through reflection get missed.
Jackson uses reflection to serialize objects. The native image builder didn’t know User would be needed.
The fix: Runtime Hints
Spring provides a way to tell the native image builder about reflection needs.
import org.springframework.aot.hint.RuntimeHints;import org.springframework.aot.hint.RuntimeHintsRegistrar;import org.springframework.context.annotation.Configuration;import org.springframework.context.annotation.ImportRuntimeHints;
@Configuration@ImportRuntimeHints(NativeHints.MyHints.class)public class NativeHints {
static class MyHints implements RuntimeHintsRegistrar { @Override public void registerHints(RuntimeHints hints, ClassLoader classLoader) { // Register User class for reflection hints.reflection().registerType(User.class); } }}This tells GraalVM: “The User class will be accessed through reflection. Include it.”
After adding hints, the build succeeded and serialization worked.
The Second Error: Resources
Next, I tried to read a configuration file from the classpath.
@Servicepublic class ConfigLoader {
public String loadConfig() { InputStream is = getClass().getResourceAsStream("/config.json"); // ... }}Another runtime error:
java.io.IOException: Resource not found: config.jsonNative images don’t automatically include classpath resources. You need to explicitly declare them.
The fix: Register resources
static class MyHints implements RuntimeHintsRegistrar { @Override public void registerHints(RuntimeHints hints, ClassLoader classLoader) { // Register reflection hints.reflection().registerType(User.class);
// Register resources hints.resources().registerPattern("config.json"); }}The Third Error: Dynamic Proxies
My application used Spring’s @Transactional annotation. Behind the scenes, Spring creates dynamic proxies for transactional beans.
java.lang.IllegalArgumentException: Could not create proxy at org.springframework.aop.framework.CglibAopProxy...Dynamic proxies need special handling in native images.
The fix: Register proxies
@Overridepublic void registerHints(RuntimeHints hints, ClassLoader classLoader) { hints.reflection().registerType(User.class);
hints.resources().registerPattern("config.json");
// Register proxies hints.proxies().registerJdkProxy(UserService.class);}A Pattern Emerges
I noticed a pattern. Every time I used a dynamic feature—reflection, proxies, resources, serialization—I needed to register it.
Spring Boot 3.x helps with this through AOT processing. It analyzes your application at build time and generates hints automatically. But third-party libraries often need manual configuration.
The most common issues:
+---------------------------+---------------------------+| Feature | Fix |+---------------------------+---------------------------+| JSON serialization | Register types for || (Jackson, Gson) | reflection |+---------------------------+---------------------------+| Classpath resources | Register resource || (properties files, XML) | patterns |+---------------------------+---------------------------+| Dynamic proxies | Register proxy interfaces || (@Transactional, AOP) | |+---------------------------+---------------------------+| JDBC drivers | Register driver classes |+---------------------------+---------------------------+| Logging frameworks | Often need hints for || (Logback) | configuration files |+---------------------------+---------------------------+Spring AOT Processing
Spring Boot 3 introduced Ahead-Of-Time processing. It converts your Spring configuration to code at build time, eliminating reflection where possible.
This means:
- Bean definitions are generated as code, not discovered via classpath scanning
- Configuration is resolved at build time
- Less runtime reflection needed
When you run ./mvnw -Pnative native:compile, Spring AOT processing happens first. It generates code like this:
public class Application_AotConfiguration { public static void registerBeanDefinitions(BeanDefinitionRegistry registry) { // Bean definitions generated at build time RootBeanDefinition userControllerDef = new RootBeanDefinition(); userControllerDef.setBeanClass(UserController.class); registry.registerBeanDefinition("userController", userControllerDef); }}This is why Spring Boot applications are easier to compile to native images than plain Java apps. Spring does a lot of the work for you.
Testing Native Images
Building a native image takes minutes. Running tests against it is slow. The solution: test in two modes.
Mode 1: JVM Tests (fast)
./mvnw testThese tests run against the regular JVM application. Fast feedback loop.
Mode 2: Native Tests (slow but thorough)
./mvnw -PnativeTest testThese tests run against the actual native executable. Slower, but catches native-specific issues.
I recommend running JVM tests frequently during development. Run native tests before releases or when changing reflection-heavy code.
Build Time vs Startup Time
The trade-off is real. Native images shift work from runtime to build time.
JVM Mode Native Image -------- ------------Build time 10-30 seconds 2-5 minutesStartup time 5-15 seconds 50-200 msMemory usage 200-500 MB 20-80 MBJIT optimization Yes No (pre-compiled)Dynamic features Full support Requires hintsNative images are ideal for:
- Serverless functions (AWS Lambda, Google Cloud Functions)
- Containerized microservices
- CLI tools
- Short-lived processes
They are NOT ideal for:
- Long-running services that benefit from JIT optimization
- Applications heavy on dynamic class loading
- Development mode (slow build times)
Docker Integration
Building native images inside Docker ensures consistency.
# Build stageFROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /appCOPY . .RUN ./mvnw -Pnative native:compile
# Runtime stageFROM gcr.io/distroless/static-debian12
COPY --from=builder /app/target/myapp /appEXPOSE 8080ENTRYPOINT ["/app"]The distroless/static image is tiny—around 2 MB base. Your application binary adds to that. Total image size can be under 50 MB.
docker build -t myapp-native .docker run -p 8080:8080 myapp-nativeCommon Mistakes I Made
Mistake #1: Ignoring Test Failures in Native Mode
I skipped native tests because they were slow. Bad idea. Reflection issues that didn’t appear in JVM mode surfaced in production.
Solution: Always run ./mvnw -PnativeTest test before deploying native images.
Mistake #2: Assuming All Libraries Work
Not every library supports native images out of the box. Older libraries especially may need manual configuration.
Solution: Check library documentation for native image support. Spring’s ecosystem is generally well-supported. Third-party libraries vary.
Mistake #3: Forgetting to Register Serialization Types
When your REST API returns objects, Jackson serializes them. Every DTO needs reflection hints.
Solution: Create a centralized hints registrar:
@Componentpublic class SerializationHints implements RuntimeHintsRegistrar { @Override public void registerHints(RuntimeHints hints, ClassLoader classLoader) { // Register all your DTOs hints.reflection().registerTypes( User.class, UserResponse.class, ErrorResponse.class ); }}Mistake #4: Not Testing Memory Footprint
I assumed native images would automatically use less memory. But if your application loads large data structures at startup, memory savings diminish.
Solution: Profile your native image with:
./target/myapp &jcmd <pid> VM.native_memory summaryWhen to Use Native Images
Based on my experience, native images make sense when:
- Startup time matters - Serverless, scheduled jobs, CLI tools
- Memory is constrained - Containers with limits, edge devices
- You control the classpath - Minimal third-party dependencies
- You can afford slow builds - CI/CD pipelines with caching
Avoid native images when:
- Development iteration speed matters - Slow builds hurt productivity
- Maximum throughput is critical - JIT can outperform AOT for long-running services
- Heavy reliance on dynamic features - Lots of reflection, dynamic class loading
- Team lacks experience - Debugging native image issues requires deeper knowledge
A Practical Workflow
Here’s the workflow I settled on:
Development: 1. Write code, run in JVM mode (fast feedback) 2. ./mvnw spring-boot:run 3. Iterate quickly
Before Release: 1. Add runtime hints for new features 2. ./mvnw -PnativeTest test 3. ./mvnw -Pnative native:compile 4. Test the executable manually 5. Build Docker image 6. Deploy
CI/CD Pipeline: 1. Run JVM tests on every PR 2. Run native tests on main branch 3. Build native Docker image for releaseThis gives me fast development iteration while catching native-specific issues early.
Summary
Native images solve a real problem: slow JVM startup. But they introduce complexity. The main challenges are:
- Reflection requires hints - Every dynamically accessed class needs registration
- Build times are longer - Minutes instead of seconds
- Dynamic features need configuration - Resources, proxies, serialization
Spring Boot 3.x helps through AOT processing. It generates code at build time, reducing runtime reflection. But you still need to understand what your application does dynamically.
If you’re deploying to serverless or need fast container scaling, native images are worth the effort. If you’re building a long-running monolithic service, stick with the JVM—the JIT will serve you better.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments