DEV Community

Avaneesh Yadav
Avaneesh Yadav

Posted on Originally published at buildingai.in

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

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>
Enter fullscreen mode Exit fullscreen mode

Build the native binary:

./mvnw -Pnative native:compile
Enter fullscreen mode Exit fullscreen mode

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

./mvnw -Pnative spring-boot:build-image
Enter fullscreen mode Exit fullscreen mode

Gradle Setup

plugins {
  id 'org.graalvm.buildtools.native' version '0.10.3'
}

graalvmNative {
  binaries {
    main {
      imageName = 'my-service'
      buildArgs.add('--no-fallback')
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
./gradlew nativeCompile
Enter fullscreen mode Exit fullscreen mode

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 {
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Then annotate your main class:

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

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);
Enter fullscreen mode Exit fullscreen mode

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/*");
Enter fullscreen mode Exit fullscreen mode

Or in application.properties:

spring.aot.hint.resources.patterns=templates/*,messages/**
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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() { }
}
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)