Skip to content

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:

JVM Startup Process
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 serve

Each 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).

Native Image Process
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 |
+------------------+
|
v
Native Executable
|
v
+------------------+
| Runtime |
| -------- |
| Instant Start | <-- No class loading, no JIT warmup
| Low Memory | <-- Only loaded code in memory
+------------------+

The benefits are real:

MetricJVMNative Image
Startup time5-15 seconds50-200 ms
Memory usage200-500 MB20-80 MB
Package sizeJAR (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 GraalVM
# Install SDKMAN if you don't have it
curl -s "https://get.sdkman.io" | bash
# Install GraalVM
sdk install java 21.0.2-graal
sdk use java 21.0.2-graal
# Verify installation
java -version
# Output should mention "GraalVM"

Step 2: Install Native Image Tool

Install native-image tool
gu install native-image
# Verify
native-image --version

Step 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.

pom.xml
<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 tool
  • process-aot goal: Spring’s ahead-of-time processing

Building the native image:

Build native image with Maven
./mvnw -Pnative native:compile

The first build took 3 minutes on my machine. The result was a single executable file in target/ directory.

Running it:

Run the native executable
./target/myapp
# The startup was almost instant:
# Started Application in 0.089 seconds

0.089 seconds. Compared to 12 seconds before. The difference is dramatic.

The First Error: Reflection

My excitement lasted until I added JSON serialization.

UserController.java
@RestController
public class UserController {
@GetMapping("/user")
public User getUser() {
return new User("John", "[email protected]");
}
}

Running the native executable, I got:

Runtime Error
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.

NativeHints.java
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.

ConfigLoader.java
@Service
public class ConfigLoader {
public String loadConfig() {
InputStream is = getClass().getResourceAsStream("/config.json");
// ...
}
}

Another runtime error:

Resource Not Found
java.io.IOException: Resource not found: config.json

Native images don’t automatically include classpath resources. You need to explicitly declare them.

The fix: Register resources

ResourceHints.java
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.

Proxy Error
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

ProxyHints.java
@Override
public 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:

Common Native Image 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:

Generated AOT Code (example)
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)

Run JVM tests
./mvnw test

These tests run against the regular JVM application. Fast feedback loop.

Mode 2: Native Tests (slow but thorough)

Run native tests
./mvnw -PnativeTest test

These 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.

Build vs Runtime Comparison
JVM Mode Native Image
-------- ------------
Build time 10-30 seconds 2-5 minutes
Startup time 5-15 seconds 50-200 ms
Memory usage 200-500 MB 20-80 MB
JIT optimization Yes No (pre-compiled)
Dynamic features Full support Requires hints

Native 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.

Dockerfile for Native Image
# Build stage
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /app
COPY . .
RUN ./mvnw -Pnative native:compile
# Runtime stage
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/target/myapp /app
EXPOSE 8080
ENTRYPOINT ["/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.

Build and run Docker image
docker build -t myapp-native .
docker run -p 8080:8080 myapp-native

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

SerializationHints.java
@Component
public 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:

Profile native image memory
./target/myapp &
jcmd <pid> VM.native_memory summary

When to Use Native Images

Based on my experience, native images make sense when:

  1. Startup time matters - Serverless, scheduled jobs, CLI tools
  2. Memory is constrained - Containers with limits, edge devices
  3. You control the classpath - Minimal third-party dependencies
  4. You can afford slow builds - CI/CD pipelines with caching

Avoid native images when:

  1. Development iteration speed matters - Slow builds hurt productivity
  2. Maximum throughput is critical - JIT can outperform AOT for long-running services
  3. Heavy reliance on dynamic features - Lots of reflection, dynamic class loading
  4. Team lacks experience - Debugging native image issues requires deeper knowledge

A Practical Workflow

Here’s the workflow I settled on:

Native Image Development Workflow
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 release

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

  1. Reflection requires hints - Every dynamically accessed class needs registration
  2. Build times are longer - Minutes instead of seconds
  3. 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