Pavan Rangani

HomeBlogGraalVM Native Image with Spring Boot: Sub-Second Startup in 2026

GraalVM Native Image with Spring Boot: Sub-Second Startup in 2026

By Pavan Rangani · February 17, 2026 · Java & Spring

GraalVM Native Image with Spring Boot: Sub-Second Startup in 2026

GraalVM Native Images with Spring Boot: Fast Startup, Low Memory, Real Trade-offs

A typical Spring Boot application takes 5-15 seconds to start and consumes 200-500MB of memory. GraalVM native images compile your application ahead-of-time into a standalone binary that starts in 50-200 milliseconds and uses 50-100MB of memory. Therefore, this guide covers the practical aspects of native image compilation with Spring Boot — what works, what breaks, and how to handle the trade-offs in production.

Why Native Images? The Container World Changed the Rules

In the era of always-on application servers, JVM startup time didn’t matter — your app started once and ran for months. In the container era, however, startup time matters constantly. Kubernetes scales pods up and down based on load, serverless functions cold-start on every invocation, and CI/CD pipelines run integration tests against fresh instances. Moreover, cloud costs are directly tied to memory consumption, so a 5x memory reduction often translates into a proportional cost reduction for memory-bound workloads. Consequently, the calculus that made the JVM’s slow startup acceptable for a decade simply no longer holds for elastic, ephemeral deployments.

JVM vs Native Image Comparison (Spring Boot REST API):

                    JVM (OpenJDK 21)    Native Image (GraalVM)
Startup time:       4.2 seconds         0.08 seconds (52x faster)
Memory (RSS):       380 MB              72 MB (5.3x less)
First response:     4.5 seconds         0.12 seconds
Binary size:        18 MB (JAR)         85 MB (native binary)
Build time:         30 seconds          4-8 minutes
Peak throughput:    45,000 req/s        38,000 req/s (15% less)
Warmup to peak:     30-60 seconds       Immediate

The trade-off is clear. Native images win at startup, memory, and initial response time, while the JVM wins at peak throughput (thanks to JIT optimization) and build time. For microservices that need fast scaling and low memory, native images are compelling. For long-running services that need maximum throughput, the JVM is still better. These figures are representative of published Spring Boot benchmarks; your own numbers will vary with workload, allocation profile, and garbage collector choice.

Building Native Images with Spring Boot 3

Spring Boot 3 has first-class support for GraalVM native images through Spring AOT (Ahead-of-Time) processing. The AOT engine analyzes your application at build time, generates optimized code, and produces the metadata GraalVM needs for native compilation.

# Prerequisites: GraalVM JDK 21+ installed
# Install native-image component
gu install native-image

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

# Or with Gradle
./gradlew nativeCompile

# Or build as a container image (no local GraalVM needed)
./mvnw -Pnative spring-boot:build-image
# Uses Paketo buildpacks — works in CI/CD without GraalVM installation
<!-- pom.xml — Spring Boot native image configuration -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.0</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
</dependencies>

<profiles>
    <profile>
        <id>native</id>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.graalvm.buildtools</groupId>
                    <artifactId>native-maven-plugin</artifactId>
                    <configuration>
                        <buildArgs>
                            <buildArg>--gc=G1</buildArg>
                            <buildArg>-march=compatibility</buildArg>
                        </buildArgs>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>

Two build arguments above are worth calling out. The --gc=G1 flag swaps the default Serial GC for G1, which pays off once your heap grows past a few hundred megabytes; the Serial collector is fine for tiny serverless functions but pauses noticeably under sustained allocation. Meanwhile, -march=compatibility produces a binary that runs on older CPUs at the cost of skipping newer instruction sets. In contrast, if you control the exact target hardware, -march=native squeezes out extra throughput. Choose deliberately, because a binary built with native will crash with an illegal instruction on a CPU that lacks those extensions.

GraalVM native image compilation process
Native image compilation transforms your Spring Boot app into a standalone binary with 50ms startup

Reflection Configuration: The Biggest Pain Point

GraalVM native images analyze all reachable code at build time and include only what is provably used. Reflection, dynamic proxies, and runtime class loading break this closed-world analysis, because the compiler cannot determine at build time which classes will be accessed reflectively. Consequently, you need to provide explicit configuration telling GraalVM about that reflective access. Otherwise the binary compiles cleanly, only to throw ClassNotFoundException or MissingReflectionRegistrationError at runtime — a class of failure that never appears on the JVM.

// Spring Boot 3 handles most reflection configuration automatically
// through AOT processing. But custom code may need hints:

// Option 1: @RegisterReflectionForBinding — simplest approach
@RegisterReflectionForBinding({OrderDTO.class, CustomerDTO.class})
@RestController
public class OrderController {
    // OrderDTO and CustomerDTO are registered for JSON serialization
}

// Option 2: RuntimeHints for complex scenarios
@ImportRuntimeHints(MyRuntimeHints.class)
@SpringBootApplication
public class Application {
    static class MyRuntimeHints implements RuntimeHintsRegistrar {
        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
            // Register classes for reflection
            hints.reflection().registerType(
                MyDynamicClass.class,
                MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                MemberCategory.INVOKE_DECLARED_METHODS
            );

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

            // Register proxies
            hints.proxies().registerJdkProxy(
                MyInterface.class, SpringProxy.class
            );
        }
    }
}

// Option 3: Use the GraalVM tracing agent to auto-discover reflection
// Run tests with tracing agent to generate config:
// java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
//      -jar myapp.jar

Libraries that heavily use reflection — some JSON processors, ORM lazy-loading features, and serialization frameworks — may require manual configuration. Spring Boot 3 handles most Spring-specific reflection automatically, but third-party libraries can still need hints. The GraalVM tracing agent helps here by recording reflective access while your test suite runs and emitting the necessary configuration files. The catch is coverage: the agent only sees code paths your tests actually exercise, so a rarely-hit error branch that uses reflection will be missed unless your integration tests deliberately trigger it. As a result, treating the agent output as a starting point rather than a complete answer is the safer mindset.

AOT Processing: What Spring Does at Build Time

Spring AOT replaces runtime bean creation with build-time code generation. Instead of scanning the classpath and creating bean definitions at startup, AOT generates Java source code that directly instantiates beans, resolves dependencies, and applies configuration. This is precisely why native images start so fast: there is no component scanning, no annotation processing, and no proxy generation at runtime.

// What Spring does at runtime (JVM mode):
// 1. Scan classpath for @Component/@Service/@Repository annotations
// 2. Parse @Configuration classes
// 3. Create bean definitions
// 4. Resolve dependencies
// 5. Create CGLIB proxies for @Transactional, @Cacheable
// This takes 3-5 seconds

// What Spring AOT generates at build time (native mode):
// Generated code that does all of the above directly
public class Application__BeanDefinitions {
    public static BeanDefinition getOrderServiceBeanDefinition() {
        return BeanDefinitionBuilder
            .rootBeanDefinition(OrderService.class)
            .setInstanceSupplier(() -> new OrderService(
                new OrderRepository(dataSource),
                new PaymentClient(webClient)
            ))
            .getBeanDefinition();
    }
}
// Startup: just execute the generated code — milliseconds

One important consequence of build-time wiring is that conditional configuration is frozen at build time, not at startup. On the JVM, a @ConditionalOnProperty bean is evaluated when the context loads, so the same JAR behaves differently depending on an environment variable. In a native image, by contrast, AOT decides which branch wins during compilation. Therefore, profile-specific beans should be selected at build time with the right active profile, and you cannot flip a feature on with a runtime property if its bean was compiled out. This is a frequent source of confusion when teams first migrate.

Spring Boot AOT processing workflow
AOT processing replaces runtime scanning with build-time code generation

Testing, Debugging, and CI: The Operational Reality

Adopting native images changes your workflow as much as your runtime, so plan for it. Because a full native compile takes minutes, almost nobody runs it on every save; instead, teams develop and unit-test on the regular JVM and reserve native builds for a dedicated CI stage that runs the suite against the compiled binary. Spring Boot supports this directly with native testing, which executes your tests in AOT-processed mode and catches reflection or resource gaps before they reach production.

# Run the test suite against AOT-processed code on the JVM (fast feedback)
./mvnw -PnativeTest test

# Full native compilation + tests in a dedicated CI stage (slow, thorough)
./mvnw -Pnative native:compile

# Debug a missing-resource failure by listing what was embedded
native-image --list-modules
# Add -H:+ReportExceptionStackTraces to surface the real cause at runtime

Debugging deserves its own warning. A native binary has no JVM underneath, so JVM-attaching profilers, JMX, and most agent-based tools simply do not apply. Instead, you observe the process with native tooling such as perf, and you can ship debug symbols and even build a debug-info image for use with gdb. GraalVM also supports generating a heap dump on demand, though the surrounding ecosystem is thinner than the mature JVM tooling you may be used to. Budget extra time for the first production incident, because the muscle memory built up over years of JVM debugging transfers only partially.

Performance Trade-offs and Limitations

Native images have real limitations you need to understand before committing. Peak throughput is roughly 10-20% lower than the JVM because there is no JIT compiler optimizing hot paths at runtime; the binary executes the same machine code on request one and request one million. Build times are 10-30x longer, which slows development cycles and lengthens CI. Some libraries still do not support native images at all, and as noted above, profiling and debugging tools remain less mature. Profile-Guided Optimization (PGO) can claw back much of the throughput gap by feeding a representative runtime profile into a second compile, but it is an Oracle GraalVM feature and adds yet another build step.

Use native images when: startup time matters (Kubernetes autoscaling, serverless, CLI tools), memory cost is significant (running many small services), or you need instant peak performance without warmup.

Stay on the JVM when: maximum throughput is critical, build time matters for developer productivity, you depend on libraries without native image support, or you rely on advanced JVM profiling and debugging. In short, do not pay the native tax for a long-lived, throughput-bound monolith that starts once a week — the JVM’s JIT and tooling will serve it better.

GraalVM performance benchmarks
Native images trade 10-20% peak throughput for 50x faster startup and 5x less memory

Related Reading:

Resources:

In conclusion, GraalVM native images with Spring Boot deliver transformative improvements in startup time and memory usage at the cost of build time and peak throughput. They are ideal for containerized microservices, serverless functions, and CLI tools, provided you account for reflection configuration and a thinner debugging story. For long-running, throughput-critical services, the JVM with JIT compilation remains the better choice — and choosing correctly between the two is the real skill.

← Back to all articles