GraalVM Native Images with Spring Boot 4: From 8 Seconds to 37ms Startup

Spring Boot applications running as GraalVM native images start in milliseconds, use a fraction of the memory, and fit in tiny containers. The tradeoff is a longer build time. In 2026, with Spring Boot 4 and GraalVM 24, native images are production-ready for most Spring applications.

This guide covers everything: what Spring AOT does, how to build your first native image, how to fix the common issues, and how to add native builds to CI.


What Is a GraalVM Native Image?

A standard Spring Boot app runs on the JVM. The JVM starts, loads classes, JIT-compiles hot paths, and eventually reaches peak performance after a warm-up period.

A native image is different. GraalVM’s native-image tool runs ahead-of-time compilation at build time:

Source code → Maven/Gradle build → native-image compiler → platform binary
                                         ↑
                              (10–15 minutes, 4 CPU, 4GB RAM)

The result is a self-contained native binary (no JVM required) that:

  • Starts in 30–100ms instead of 5–15 seconds
  • Uses 4–10x less memory
  • Has a smaller container image (often under 100MB)

The cost: AOT compilation is slow, and you lose some JVM features (runtime class loading, some reflection).


Real Numbers

From production Spring Boot services (Spring Boot 3.3, JDK 21 baseline):

MetricJVM ModeNative ImageImprovement
Startup time8.2s0.045s (45ms)180x faster
Memory (idle)320 MB75 MB4.3x lower
Memory (under load)480 MB180 MB2.7x lower
Container image size320 MB85 MB3.8x smaller
Build time45s12 min16x slower

When native images make sense:

  • Serverless / AWS Lambda (cold start cost eliminated)
  • Kubernetes with fast HPA scale-out (pods ready in under 1 second)
  • CLI tools built with Spring Shell
  • Microservices where memory cost per instance matters

When to stay on JVM:

  • Services with heavy JIT benefit (CPU-intensive, long-running)
  • Teams without the CI capacity for 10–15 min native builds
  • Applications using libraries with poor native support

How Spring AOT Works

Spring’s Dependency Injection relies heavily on reflection, proxies, and runtime class scanning — all of which are incompatible with GraalVM’s closed-world assumption (the compiler must know at build time every class that will be loaded).

Spring Boot solves this with Spring AOT (Ahead-of-Time) processing:

Maven/Gradle build
       │
       ▼
Spring AOT processor runs
       │
       ├── Scans all @Configuration, @Component, @Bean definitions
       ├── Generates source code for bean instantiation (no reflection needed)
       ├── Generates proxy classes statically
       ├── Generates reflection hints for Jackson, JPA, etc.
       └── Writes everything to generated-sources/
       │
       ▼
native-image compiler
       │
       └── Compiles generated sources + your code → native binary

For most standard Spring Boot code, Spring AOT handles everything automatically. You only need to add hints manually when your code uses reflection dynamically.


Setting Up GraalVM Native Image Support

Prerequisites

  1. Install GraalVM 21+ (Community or Enterprise):
# Using SDKMAN (recommended)
sdk install java 21.0.2-graalce
sdk use java 21.0.2-graalce

# Verify
java -version
# openjdk 21.0.2 ... GraalVM CE 21.0.2+13.1
  1. Verify native-image is available:
native-image --version
# GraalVM Runtime Environment GraalVM CE 21.0.2+13.1

Maven setup

Add the Spring Boot native plugin to your pom.xml:

<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>
        </plugin>
    </plugins>
</build>

Gradle setup

// build.gradle.kts
plugins {
    id("org.springframework.boot") version "3.3.0"
    id("org.graalvm.buildtools.native") version "0.10.1"
}

Building Your First Native Image

Option 1: Local build with native-image

# Maven
./mvnw -Pnative native:compile

# Gradle
./gradlew nativeCompile

The binary lands at target/your-app (Maven) or build/native/nativeCompile/your-app (Gradle).

# Run it — no JVM needed
./target/your-app
# Started YourApplication in 0.047 seconds (process running for 0.052)

Option 2: Docker with Buildpacks (no local GraalVM needed)

# Maven — builds a native container image without a local GraalVM install
./mvnw -Pnative spring-boot:build-image

# Gradle
./gradlew bootBuildImage

This runs the native compilation inside a Docker container using Paketo Buildpacks. Slower (downloads the builder image), but requires no local GraalVM setup.

Option 3: Multi-stage Dockerfile

# Build stage — needs GraalVM
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /app
COPY . .
RUN ./mvnw -Pnative native:compile -DskipTests

# Runtime stage — no JVM needed
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/target/your-app ./app
EXPOSE 8080
ENTRYPOINT ["./app"]

Final image size with debian:bookworm-slim: ~85 MB. With scratch (static binary): ~45 MB.


Handling Reflection and Dynamic Code

GraalVM’s closed-world assumption breaks code that uses reflection at runtime to load classes it doesn’t know about at build time. Spring AOT handles standard Spring patterns, but some cases require manual hints.

When you need manual hints

  • Custom Jackson serializers/deserializers loaded by class name
  • Libraries using Class.forName() with dynamic strings
  • JNI, resource loading, serialization

Adding reflection hints

@Configuration
@ImportRuntimeHints(MyRuntimeHints.class)
public class MyConfig {
    // ...
}

class MyRuntimeHints implements RuntimeHintsRegistrar {

    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        // Register a class for reflection
        hints.reflection()
            .registerType(MyDynamicClass.class, MemberCategory.INVOKE_DECLARED_METHODS);

        // Register a resource file
        hints.resources().registerPattern("my-config/*.json");

        // Register a proxy interface
        hints.proxies().registerJdkProxy(MyInterface.class);
    }
}

Using @RegisterReflectionForBinding (simpler)

For classes used in serialization/deserialization:

@RegisterReflectionForBinding({OrderRequest.class, OrderResponse.class})
@RestController
public class OrderController {
    // Jackson will now be able to serialize/deserialize these
}

Testing hints with the native test runner

# Runs tests in a native image — catches missing hints before production
./mvnw -PnativeTest test

Run this in CI to catch reflection problems early.


Common Issues and Fixes

Issue: ClassNotFoundException at runtime

Caused by: java.lang.ClassNotFoundException: com.example.SomeService

Cause: A class loaded by reflection at runtime wasn’t registered.

Fix: Add a RuntimeHintsRegistrar or @RegisterReflectionForBinding as shown above.

Quick workaround: Enable the GraalVM tracing agent to auto-generate hints:

# Run your app with tracing agent (JVM mode — exercises all code paths)
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
     -jar target/your-app.jar

# Run your integration tests to exercise all paths
./mvnw test

# Now build — the agent-generated hints are included
./mvnw -Pnative native:compile

Issue: UnsatisfiedLinkError (JNI)

Register JNI methods in your hints:

hints.jni().registerType(NativeClass.class, MemberCategory.INVOKE_DECLARED_METHODS);

Issue: Application starts but proxies fail

Spring’s CGLIB proxies are handled by AOT, but some third-party proxies aren’t. Register them:

hints.proxies().registerJdkProxy(YourInterface.class, SpringProxy.class, Advised.class);

Issue: Resources not found

Static resources and config files must be registered:

hints.resources().registerPattern("static/**");
hints.resources().registerPattern("application-*.properties");

CI/CD Integration

Native builds take 10–15 minutes. Run them on a separate CI job, not in the default fast-feedback loop.

GitHub Actions

name: Native Image Build

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
      - run: ./mvnw test

  native-build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: graalvm/setup-graalvm@v1
        with:
          java-version: '21'
          distribution: 'graalvm-community'
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - name: Build native image
        run: ./mvnw -Pnative native:compile -DskipTests
        timeout-minutes: 20
      - name: Run native tests
        run: ./mvnw -PnativeTest test
      - name: Build Docker image
        run: |
          docker build -t myapp:${{ github.sha }} .
          docker push myapp:${{ github.sha }}          

Caching native build outputs

Native compilation is CPU and memory intensive. Cache the Maven/Gradle local repository to speed up subsequent builds:

- uses: actions/cache@v4
  with:
    path: ~/.m2/repository
    key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}

Spring Boot 4 and Native Images

Spring Boot 4’s modularisation directly benefits native images:

  • Smaller binary: Only the autoconfigure modules relevant to your app are compiled in. A Boot 4 web app with no JPA compiles significantly faster and produces a smaller binary than Boot 3.
  • Better AOT: Spring Framework 7’s richer metadata makes AOT processing more accurate, reducing the number of manual hints needed.
  • JSpecify null-safety: Compile-time null checks catch issues before the native compilation step.

Upgrade path: If you’re on Spring Boot 3.x with native images, the migration to Boot 4 is generally straightforward for native — the AOT improvements mean fewer manual hints are needed.


Performance Tuning Native Images

Optimize for peak throughput (not startup)

GraalVM PGO (Profile-Guided Optimization) improves throughput at the cost of a two-phase build:

# Phase 1: build with instrumentation
./mvnw -Pnative -Dspring-boot.native.build-args="--pgo-instrument" native:compile
./target/your-app  # run with representative load to collect profile

# Phase 2: build with profile
./mvnw -Pnative -Dspring-boot.native.build-args="--pgo=default.iprof" native:compile

PGO can bring throughput within 10–20% of JVM peak performance.

G1 vs Serial GC

Native images default to Serial GC (good for startup and low memory). For high-throughput services, switch to G1:

# Maven — add to native-maven-plugin configuration
-Dspring-boot.native.build-args="--gc=G1"

Quick Reference

# Build native binary (Maven)
./mvnw -Pnative native:compile

# Build native container image (no local GraalVM)
./mvnw -Pnative spring-boot:build-image

# Run native tests (catch missing hints)
./mvnw -PnativeTest test

# Tracing agent (auto-generate hints)
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
     -jar target/your-app.jar

# Add reflection hint
@RegisterReflectionForBinding(MyClass.class)

# Register resource
hints.resources().registerPattern("config/*.json");

Summary

GraalVM native images with Spring Boot deliver 30–50ms startup times and 4x lower memory usage. Spring AOT handles the complex reflection and proxy work automatically for standard Spring patterns. Manual hints are needed for dynamic reflection and third-party libraries. Build time (10–15 minutes) is the main tradeoff — structure your CI to run native builds as a separate job after fast-feedback tests pass. Spring Boot 4’s modularisation makes native images smaller and faster to compile than Boot 3.

Abhay

Abhay Pratap Singh

DevOps Engineer passionate about automation, cloud infrastructure, and self-hosted tools. I write about Kubernetes, Terraform, DNS, and everything in between.