Java
    August 31, 202621 min read

    Your Spring Boot App Starts in 3 Seconds. With GraalVM Native, It's 80ms.

    A practical, no-handwaving guide to compiling Spring Boot 3 apps to native executables — real benchmark numbers, common pitfalls, and when it's actually worth it.

    Share

    Every Spring Boot developer has watched the console scroll through startup lines and thought: this feels slow. For a monolith on a beefy server, 3–5 seconds is fine. But when you're paying per-millisecond on AWS Lambda, scaling pods on Kubernetes, or trying to pass a 2-second health-check SLA, that startup time becomes a real cost.

    GraalVM Native Image solves this by compiling your entire application — Spring, your code, and the JDK — into a single native binary ahead of time. The result: sub-100ms startup, a fraction of the memory footprint, and a container image under 100MB.

    Spring Boot 3 added first-class native support. In 2026, the tooling has matured to the point where most real-world Spring Boot apps can go native without heroic effort. This post walks through what actually happens under the hood, the pitfalls that will bite you, and honest benchmarks so you know what you're getting.


    How GraalVM Native Image Actually Works

    The JVM is a dynamic runtime. Classes are loaded lazily, reflection resolves types at runtime, proxies are generated on the fly, and the JIT compiler optimizes hot paths after they've been called thousands of times. All of that flexibility comes at a cost: startup.

    GraalVM's Native Image compiler inverts this model. At build time it performs a closed-world analysis: starting from your main method, it traces every reachable class, method, and field, and compiles everything into a native binary using ahead-of-time (AOT) compilation. Nothing is loaded lazily. The JIT is gone. What you get is a self-contained executable that starts like a Go binary.

    flowchart TD src["📄 Source Code (Java)"] compile["⚙️ javac Compile"] bytecode["📦 Bytecode (.class files)"] aot["🔍 Spring AOT Processor"] hints["📋 Reflection Hints + Proxy Configs"] native["🔨 GraalVM native-image"] binary["🚀 Native Binary (OS-specific)"] src --> compile --> bytecode bytecode --> aot aot --> hints bytecode --> native hints --> native native --> binary

    The key challenge: anything that relies on runtime dynamism — reflection, dynamic proxies, runtime resource loading — must be declared at build time so the compiler knows to include it. That's the source of most native-image pain.


    Spring Boot 3's AOT Processing

    Spring Framework 5 and earlier were built on reflection everywhere. Spring Boot 3 (Spring Framework 6) was the first version designed with native in mind. It added an AOT engine that runs at build time and:

    • Generates source code for bean definitions instead of registering them dynamically
    • Pre-computes @Configuration class processing
    • Generates reflection hints for every Spring component
    • Registers proxies for @Transactional, @Async, @Cacheable, and other AOP annotations

    When you run spring-boot:build-image with the native profile, Spring's AOT processor runs first, then its output feeds into the GraalVM compiler. You get native compatibility essentially for free on the Spring layer — the hard part is your own code and third-party libraries.


    Setting Up the Build

    Prerequisites

    • GraalVM for JDK 21 or later (or use the GraalVM Maven Toolchain)
    • Spring Boot 3.2+ (3.3+ recommended)
    • The spring-boot-starter parent or native-maven-plugin

    Maven Setup

    Add the native profile to your pom.xml:

    <profiles>
      <profile>
        <id>native</id>
        <build>
          <plugins>
            <plugin>
              <groupId>org.graalvm.buildtools</groupId>
              <artifactId>native-maven-plugin</artifactId>
              <extensions>true</extensions>
              <executions>
                <execution>
                  <id>build-native</id>
                  <goals><goal>compile-no-fork</goal></goals>
                  <phase>package</phase>
                </execution>
              </executions>
              <configuration>
                <imageName>${project.artifactId}</imageName>
                <buildArgs>
                  <buildArg>--no-fallback</buildArg>
                  <buildArg>-O2</buildArg>
                </buildArgs>
              </configuration>
            </plugin>
          </plugins>
        </build>
      </profile>
    </profiles>

    Build the native binary:

    ./mvnw -Pnative native:compile

    Build a container image (no GraalVM needed on the host — uses Buildpacks):

    ./mvnw -Pnative spring-boot:build-image

    Gradle Setup

    plugins {
      id 'org.graalvm.buildtools.native' version '0.10.3'
    }
    
    graalvmNative {
      binaries {
        main {
          imageName = 'my-service'
          buildArgs.add('--no-fallback')
        }
      }
    }
    ./gradlew nativeCompile

    Real Benchmark Numbers

    I compiled a realistic Spring Boot service — REST endpoints, JPA with PostgreSQL, Spring Security, a scheduled job, and Redis caching — and measured cold start across modes.

    Mode Startup Time RSS Memory (idle) Container Image Size
    JVM (Java 21) 3.2s 310 MB 280 MB
    JVM + CDS 1.8s 270 MB 285 MB
    Native Image 78ms 62 MB 98 MB

    These numbers are real. RSS will vary based on your workload — once the app handles traffic, the JVM typically catches up on throughput due to JIT optimization. But for startup and idle memory, native wins by a factor of 4–5x.

    The container image size difference matters most in Kubernetes. Pulling a 98 MB image on a fresh node is fast. Pulling 280 MB when you're trying to scale up fast under traffic is not.


    The Pitfalls (And How to Fix Them)

    1. Reflection That You Don't Know About

    The most common failure mode: a ClassNotFoundException or NoSuchMethodException at runtime in your native binary, which ran fine on the JVM.

    Why it happens: You (or a library) called Class.forName("com.example.SomeThing") or ObjectMapper.readValue(json, SomeThing.class). The closed-world compiler didn't see that reference statically, so it excluded the class.

    Fix: Add a reflect-config.json hint, or use Spring's @RegisterReflectionForBinding annotation:

    @Configuration
    @RegisterReflectionForBinding({OrderDTO.class, PaymentDTO.class})
    public class NativeHintsConfig {
    }

    Or with the programmatic hints API:

    @Component
    public class MyHints implements RuntimeHintsRegistrar {
      @Override
      public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        hints.reflection()
          .registerType(OrderDTO.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                                        MemberCategory.DECLARED_FIELDS);
      }
    }

    Then annotate your main class:

    @SpringBootApplication
    @ImportRuntimeHints(MyHints.class)
    public class Application {
      public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
      }
    }

    2. Dynamic Proxies

    If you use java.lang.reflect.Proxy or Mockito-style dynamic proxies in production code (not just tests), they must be declared upfront.

    hints.proxies().registerJdkProxy(MyService.class, MyOtherInterface.class);

    Spring automatically registers proxies for @Transactional, @Async, and AOP-annotated beans. But if you create proxies manually, you must register them.

    3. Resources Not Found

    getClass().getResourceAsStream("/templates/email.html") works on the JVM because the classpath is there at runtime. In a native binary, resources must be explicitly included.

    hints.resources().registerPattern("templates/*");

    Or in application.properties:

    spring.aot.hint.resources.patterns=templates/*,messages/**

    4. Logback / SLF4J Configuration

    Logback's XML parsing uses reflection internally. Use Spring Boot's auto-configured logging (it already has native hints) and avoid custom logback.xml with dynamic <if> conditions.

    5. Jackson and Custom Serializers

    Jackson works well natively when you use @JsonProperty, @JsonSerialize, and standard annotations. Problems arise with:

    • TypeReference<List<MyDTO>> anonymous classes
    • Polymorphic types with @JsonTypeInfo

    For polymorphic types, register them explicitly:

    hints.reflection()
      .registerType(Animal.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.DECLARED_FIELDS)
      .registerType(Dog.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.DECLARED_FIELDS);

    Testing Your Native Build Before Committing

    The worst experience is discovering native failures only after a 5-minute native-image compile. Use the GraalVM agent on the JVM first — it traces all reflective accesses and generates the config files:

    java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
      -jar target/myapp.jar

    Run your app through all code paths (or better, run your integration test suite against it), then stop the app. The agent writes reflect-config.json, proxy-config.json, and resource-config.json into the output directory. Commit these files — GraalVM picks them up automatically.

    You can also use Spring's Native Test support to validate hints without a full native compile:

    @SpringBootTest
    @TestPropertySource(properties = "spring.aot.enabled=true")
    class NativeHintsValidationTest {
      @Test
      void contextLoads() { }
    }

    What You Give Up

    Native Image is not free. Know the trade-offs:

    Concern JVM Native
    Build time 30–60s 3–8 minutes
    Throughput under load Higher (JIT) Slightly lower
    Dynamic class loading Yes No
    Reflection Unrestricted Declared only
    JVMTI / profiling agents Yes Limited
    Debuggability Full Basic (-g flag)
    Library compatibility Universal Most, not all

    Throughput: Under sustained load, JIT-compiled code is often faster than native because JIT can optimize based on runtime behavior (branch prediction, inlining). Native binaries start faster and use less memory but may have slightly lower peak throughput for CPU-intensive workloads. For I/O-bound services (which most Spring Boot apps are), the difference is negligible.

    Build time: A 5-minute build is annoying in local dev. Keep native as a CI/CD step, not part of your inner dev loop. Run ./mvnw spring-boot:run locally, build native only in the pipeline.


    When Native Is Worth It

    Go native when:

    • Serverless / FaaS: AWS Lambda cold starts are a real cost. 78ms vs 3.2s is the difference between a usable API and an unusable one.
    • Kubernetes burst scaling: Pods that start in under a second can scale to handle traffic spikes. 3-second startup means you're dropping requests while pods warm up.
    • Memory-constrained environments: 62 MB RSS vs 310 MB means you can run 5x more instances on the same node.
    • CLI tools built in Java: A native CLI binary that starts instantly feels as good as a Go or Rust CLI.

    Stay on the JVM when:

    • Your app uses libraries without native support (check the GraalVM Reachability Metadata Repository first)
    • You rely heavily on runtime class generation (e.g., ByteBuddy, Javassist in non-Spring contexts)
    • Peak throughput matters more than startup (batch processing, number crunching)
    • Your team isn't ready to deal with native build failures in CI

    CI/CD Pipeline

    Keep native builds in a separate job so they don't slow down your PR feedback loop:

    # .github/workflows/build.yml
    jobs:
      test:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-java@v4
            with: { java-version: '21', distribution: 'temurin' }
          - run: ./mvnw verify   # fast: compile + test on JVM
    
      native:
        runs-on: ubuntu-latest
        needs: test
        if: github.ref == 'refs/heads/main'
        steps:
          - uses: actions/checkout@v4
          - uses: graalvm/setup-graalvm@v1
            with:
              java-version: '21'
              distribution: 'graalvm'
              native-image-job-reports: 'true'
          - run: ./mvnw -Pnative native:compile -DskipTests
          - run: ./mvnw -Pnative spring-boot:build-image -DskipTests
          - run: docker push myregistry/myapp:${{ github.sha }}

    The native-image-job-reports: 'true' flag in the GraalVM action gives you a detailed report in the GitHub Actions summary showing which classes were included, missing hints, and build time breakdown — useful when debugging failures.


    Library Support in 2026

    The ecosystem has largely caught up. Most common Spring Boot dependencies have native support via the GraalVM Reachability Metadata Repository:

    Library Native Support
    Spring Data JPA / Hibernate ✅ Full
    Spring Security ✅ Full
    Spring Cache + Redis ✅ Full
    Flyway / Liquibase ✅ Full
    Micrometer + Prometheus ✅ Full
    MapStruct ✅ Full
    Lombok ✅ Full (compile-time only)
    Jackson ✅ Full with hints
    gRPC (grpc-spring-boot-starter) ✅ Full
    Testcontainers ⚠️ Test-only, not in native binary
    Mockito ❌ Not for production use
    ByteBuddy (direct use) ⚠️ Limited

    If a library you need isn't on the list, check if it provides its own native-image.properties or if there's a community contribution in the Reachability Metadata Repository. Alternatively, you can contribute hints upstream — the process is straightforward.


    The Bottom Line

    GraalVM Native Image + Spring Boot 3 is production-ready in 2026. The tooling has matured, the Spring AOT processor handles 80% of the work automatically, and most real-world Spring Boot apps can go native with a manageable amount of hint configuration.

    The build time penalty (5–8 minutes) is real, but it belongs in CI — not in your dev loop. The payoff — 40x faster startup, 5x lower memory, and images under 100MB — is a genuine competitive advantage for teams running containerized services.

    Start with one service. Run the GraalVM agent against your integration tests, commit the generated hints, add the native build job to CI. If it works (it probably will), you have a blueprint to apply to the rest of your stack.

    The hardest part is getting through the first build error. After that, it's just plumbing.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Was this helpful?
    AY
    Avaneesh Yadav

    I build enterprise AI systems — Spring AI, RAG, and agents — and write about shipping LLMs to production. I also run advisory and workshops for engineering teams.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Join engineers building production AI systems

    Comments