Spring Boot 3.x native image support is production-ready, and the performance numbers are hard to ignore. A service that took 8 seconds to start and consumed 512MB of heap at idle now starts in 150ms and runs comfortably in 64MB. For containers, serverless functions, and CLI tools, that’s a meaningful difference.

This article covers the full picture: how GraalVM native image works, what Spring Boot 3’s AOT engine does for you, how to configure the build, and how to handle the cases where you’ll still need to write hints manually.

How GraalVM Native Image Works

The JVM compiles Java bytecode to machine code at runtime via JIT. GraalVM Native Image flips this: it performs Ahead-of-Time (AOT) compilation at build time, producing a self-contained native executable with no JVM dependency.

The tradeoff is fundamentally about when work happens:

CharacteristicJVM ModeNative Image
Startup timeSeconds (JIT warm-up)Milliseconds
Memory footprintHigh (JIT metadata, heap)Low (fixed binary)
Peak throughputExcellent (JIT-optimized)Good (no JIT)
Build timeFastSlow (minutes)
Reflection supportFullRequires hints

Native image uses static analysis (called “points-to analysis”) to determine which code paths are reachable at build time. Everything not reachable gets dropped. This is what makes the binary small and memory-light—and it’s also why reflection, dynamic proxies, and runtime classpath scanning require explicit configuration.

Spring Boot 3.x AOT Engine

Spring Boot 3 introduced a built-in AOT processing step that runs during the build. Before GraalVM compiles your code, Spring:

  1. Starts the ApplicationContext in a restricted mode
  2. Processes all @Bean definitions, @Conditional logic, and auto-configuration
  3. Generates Java source files representing the pre-computed context
  4. Generates reflect-config.json, proxy-config.json, and resource-config.json hint files
  5. Packages all of this for the native image compiler

In practice, this means the majority of Spring’s reflection and proxy usage—which is pervasive—is handled automatically. You write normal Spring code and the AOT engine does the heavy lifting.

Setting Up the Native Build

Prerequisites

You need either:

  • GraalVM JDK 21+ with native-image installed: gu install native-image
  • Docker with docker buildx (for container-based native builds without a local GraalVM install)

Check your GraalVM setup:

java -version
# Should show: OpenJDK ... GraalVM CE ...

native-image --version
# Should show: native-image 21.x.x ...

Maven Configuration

Add the Spring Boot native Maven plugin:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.3</version>
</parent>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <excludes>
                    <exclude>
                        <groupId>org.projectlombok</groupId>
                        <artifactId>lombok</artifactId>
                    </exclude>
                </excludes>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.graalvm.buildtools</groupId>
            <artifactId>native-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

<profiles>
    <profile>
        <id>native</id>
        <build>
            <plugins>
                <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>
                <plugin>
                    <groupId>org.graalvm.buildtools</groupId>
                    <artifactId>native-maven-plugin</artifactId>
                    <executions>
                        <execution>
                            <id>add-reachability-metadata</id>
                            <goals>
                                <goal>add-reachability-metadata</goal>
                            </goals>
                        </execution>
                        <execution>
                            <id>build-native</id>
                            <goals>
                                <goal>compile-no-fork</goal>
                            </goals>
                            <phase>package</phase>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>

Gradle Configuration

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

graalvmNative {
    binaries {
        named("main") {
            imageName.set("my-service")
            buildArgs.add("--no-fallback")
            buildArgs.add("-O2")
        }
    }
}

Building the Native Executable

# Maven: build native executable
./mvnw -Pnative native:compile

# Gradle: build native executable
./gradlew nativeCompile

# The executable will be at target/my-app (Maven) or build/native/nativeCompile/my-app (Gradle)
./target/my-app

Expect the build to take 3–10 minutes depending on your application size and hardware.

Container Builds with Buildpacks

If you don’t have GraalVM installed locally, or need to build for a different platform, Spring Boot’s Buildpacks integration handles everything inside Docker:

# Maven: build native container image (requires Docker)
./mvnw -Pnative spring-boot:build-image

# Gradle
./gradlew bootBuildImage

This pulls a GraalVM-enabled builder image, runs AOT processing and native compilation inside the container, and produces a minimal runtime image. No local GraalVM install needed.

For CI pipelines, this is often the cleanest option:

# .github/workflows/native-build.yml
name: Build Native Image

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'graalvm'
      - name: Build native image
        run: ./mvnw -Pnative native:compile -DskipTests
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: native-executable
          path: target/my-app

Multi-Stage Dockerfile

For a production-optimized container image:

# Build stage — uses GraalVM
FROM ghcr.io/graalvm/native-image:21 AS builder

WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -q

COPY src/ src/
RUN ./mvnw -Pnative native:compile -DskipTests

# Runtime stage — minimal base image
FROM debian:bookworm-slim

RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
WORKDIR /app
COPY --from=builder /app/target/my-app ./

USER appuser
EXPOSE 8080
ENTRYPOINT ["./my-app"]

The resulting image is typically 80–150MB vs 400–600MB for a JVM-based image, and it starts in under a second.

Handling Reflection and Dynamic Proxies

This is where most native image issues live. GraalVM’s static analysis cannot see through dynamic reflection calls, so anything that uses reflection at runtime needs an explicit hint.

What Spring’s AOT Engine Handles Automatically

The good news: Spring handles most of this for you. The AOT engine generates hints for:

  • All @Component, @Service, @Repository, @Controller beans
  • @Configuration classes and @Bean methods
  • JPA entities (when using Spring Data JPA)
  • Spring Security configurations
  • All auto-configuration classes
  • Jackson serialization for types used in @RequestBody/@ResponseBody

Writing Manual Hints

For cases the AOT engine can’t see—third-party libraries, dynamic class loading, custom serialization—you register hints manually using RuntimeHintsRegistrar:

import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.stereotype.Component;

@Component
@ImportRuntimeHints(MyRuntimeHints.class)
public class MyService {
    // service implementation
}

class MyRuntimeHints implements RuntimeHintsRegistrar {

    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        // Register a class for reflection (all declared methods and fields)
        hints.reflection()
            .registerType(MyDynamicallyLoadedClass.class,
                MemberCategory.INVOKE_DECLARED_METHODS,
                MemberCategory.DECLARED_FIELDS);

        // Register a specific method
        hints.reflection()
            .registerMethod(
                ReflectionUtils.findMethod(MyClass.class, "myMethod", String.class),
                ExecutableMode.INVOKE);

        // Register resources
        hints.resources()
            .registerPattern("templates/*.html")
            .registerPattern("static/icons/*.svg");

        // Register serialization for a type
        hints.serialization()
            .registerType(MySerializableClass.class);

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

Using @RegisterReflectionForBinding

For Jackson serialization on types that Spring can’t detect automatically:

import org.springframework.aot.hint.annotation.RegisterReflectionForBinding;

@RestController
@RequestMapping("/api/events")
@RegisterReflectionForBinding({
    OrderEvent.class,
    PaymentEvent.class,
    ShipmentEvent.class
})
public class EventController {
    // ...
}

JSON Hint Files (Fallback)

For third-party libraries without native hints, you can place hint files in src/main/resources/META-INF/native-image/:

// reflect-config.json
[
  {
    "name": "com.example.ThirdPartyClass",
    "allDeclaredConstructors": true,
    "allDeclaredMethods": true,
    "allDeclaredFields": true
  }
]
// proxy-config.json
[
  ["com.example.MyInterface", "org.springframework.aop.SpringProxy"]
]

Testing Native Image Compatibility

Don’t wait until the native build to find hint gaps. The AOT test support lets you run your existing tests against the AOT-generated code (not the actual native binary, but close enough to catch most issues):

# Maven: run tests in AOT mode
./mvnw test -PnativeTest

# Gradle
./gradlew nativeTest

This runs your full test suite against the AOT-processed application context, catching reflection misses before the 10-minute native compile.

For the actual native binary, add a smoke test to your CI:

./target/my-app &
sleep 2
curl -f http://localhost:8080/actuator/health || exit 1
kill %1

Performance Benchmarks

Numbers from a representative Spring Boot 3.2 REST service with Spring Data JPA, Spring Security, and ~20 endpoints:

MetricJVM (JIT)Native Image
Startup time4.2s0.18s
First request latency120ms (JIT cold)8ms
Steady-state throughput~4,200 req/s~3,800 req/s
Idle memory (RSS)310MB68MB
Peak memory under load580MB195MB
Binary/image size280MB (fat jar)95MB (native exe)

The throughput gap (about 10%) exists because JIT-compiled code can be more aggressively optimized than AOT code. For I/O-bound services—which describes most web applications—this gap is rarely significant in practice, because the bottleneck is usually network or database, not CPU.

Where native image wins clearly: containers with memory limits, scale-to-zero serverless workloads (AWS Lambda, Google Cloud Run), and CLI tools that need to respond instantly.

Common Issues and Fixes

Missing Reflection Hints — ClassNotFoundException at Runtime

You’ll see errors like:

java.lang.ClassNotFoundException: com.example.SomeClass

or:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
No serializer found for class com.example.MyDto

Fix: add the missing type to your RuntimeHintsRegistrar or use @RegisterReflectionForBinding.

Initializing a Class Too Early

Error: Classes that should be initialized at run time got initialized during image building

Some libraries initialize static state that native image tries to bake in at build time—but that state depends on runtime conditions (e.g., system properties, environment variables).

Fix:

// In your native-maven-plugin configuration, or programmatically:
graalvmNative {
    binaries {
        named("main") {
            buildArgs.add("--initialize-at-run-time=com.example.ProblematicClass")
        }
    }
}

Accessing Sun Internal APIs

WARNING: Using incubator modules: ...
Error: com.oracle.svm.core.jdk.UnsupportedFeatureError

Some libraries use sun.* or JDK internal APIs. Check if the library has a native-image-compatible version, or file a bug upstream.

Dynamic @ConfigurationProperties

If you use @ConfigurationProperties on classes discovered dynamically (e.g., loaded from an external JAR), you need to register them explicitly:

hints.reflection()
    .registerType(MyConfigProps.class,
        MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
        MemberCategory.DECLARED_FIELDS,
        MemberCategory.INVOKE_DECLARED_METHODS);

GraalVM Reachability Metadata Repository

Before writing custom hints, check the GraalVM Reachability Metadata Repository. It contains community-contributed hint files for hundreds of popular libraries (Hibernate, Jackson, Netty, Micrometer, and many more).

The native-maven-plugin’s add-reachability-metadata goal pulls these automatically during the build. Most Spring Boot starter dependencies already have this covered.

When to Use Native Image

Native image is the right choice when:

  • Startup time matters: serverless, scale-to-zero, or CLI tools
  • Memory is constrained: dense container deployments, Kubernetes with tight resource limits
  • Distribution simplicity: shipping a single binary without a JRE dependency

Stick with the JVM when:

  • Peak throughput is critical: high-frequency trading, compute-heavy batch processing
  • Extensive dynamic class loading: plugin systems, scripting engines
  • Build time is a bottleneck: native builds add minutes to every CI run
  • Debugging and profiling: JVM tooling (async-profiler, JFR, heap dumps) doesn’t apply to native

For most microservices and APIs, native image is worth evaluating—especially if you’re already on Spring Boot 3.x. The AOT engine handles the hard parts, and the operational benefits at scale are real.

Putting It Together

A minimal native-ready Spring Boot service:

@SpringBootApplication
public class OrderServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderRepository repository;

    public OrderController(OrderRepository repository) {
        this.repository = repository;
    }

    @GetMapping("/{id}")
    public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
        return repository.findById(id)
            .map(OrderResponse::from)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
        Order order = repository.save(Order.from(request));
        return OrderResponse.from(order);
    }
}

Build and run:

# Build native
./mvnw -Pnative native:compile -DskipTests

# Run — starts in ~150ms
./target/order-service

# Output:
# Started OrderServiceApplication in 0.157 seconds (process running for 0.172)

No JVM, no warm-up, ready to serve immediately.

Spring Boot 3’s AOT engine has made native image practical for standard Spring applications. Start with ./mvnw -Pnative native:compile on your existing app—if you’re using standard Spring starters, it will probably just work.