- Published on
Spring Boot Native Images with GraalVM: Build, Test, Measure, and Decide
- Authors

- Name
- Maria
GraalVM Native Image can make a Spring Boot application start faster and use less memory, but those benefits are not free and they are not equally valuable for every service. A native executable is a separate deployment target with a different runtime model, longer builds, platform-specific artifacts, and stricter rules around reflection and other dynamic Java features.
The useful question is therefore not “Is native faster than the JVM?” It is:
Does faster startup or a smaller steady-state footprint solve a measured problem in this application, and can the team test the native artifact well enough to own its different failure modes?
This guide uses Spring Boot 4.1 and Java 25 as a concrete baseline. It shows two supported build paths, a small runtime-hints example, native testing, container packaging, and a measurement plan. More importantly, it draws a boundary between workloads that benefit from native compilation and long-running services that are often better left on a conventional JVM.
TL;DR Build a native executable only after defining the startup, memory, throughput, and build-time targets that matter. Prefer Spring Boot's Maven or Gradle integration instead of invoking
native-imageby hand. Run both JVM tests and tests against the native executable. Treat reflection, resources, serialization, proxies, and dynamically loaded classes as explicit compatibility work. Compare the complete container under the same limits and traffic; do not publish an isolated startup number as proof that one runtime is universally better.
What Changes When Java Is Compiled Ahead of Time
A normal Spring Boot deployment contains bytecode and runs on a JVM. The JVM loads classes as the application runs and can use just-in-time compilation to optimize hot paths using actual runtime behavior.
Native Image performs a closed-world analysis during the build. Starting from known entry points, it determines which classes, methods, resources, proxies, and runtime services are reachable, then produces a platform-specific executable. The executable includes a small runtime but does not require a separately installed JVM.
That difference produces the main trade-off:
| Concern | JVM deployment | Native executable |
|---|---|---|
| Startup | Class loading and runtime initialization add work | Much work is moved to build time |
| Peak throughput | JIT can optimize hot code using runtime profiles | No traditional JIT warm-up or optimization |
| Memory | JVM runtime, metadata, and code cache are present | Often a smaller process footprint |
| Build time | Usually shorter | Static analysis and native compilation are expensive |
| Portability | One JAR can run on compatible JVMs | Build for the target OS and architecture |
| Dynamic behavior | Reflection and dynamic loading generally work naturally | Some behavior needs reachability metadata |
| Diagnostics | Mature JVM profiling and debugging ecosystem | Different tools and less runtime metadata |
Spring's AOT engine makes the closed-world model practical by analyzing the application context and generating code and hints during the build. It does not make every third-party library automatically compatible. A library can still load a class by name, create a proxy dynamically, deserialize an arbitrary type, or read a resource that static analysis cannot discover.
Spring Boot's Native Image documentation is the primary reference for its AOT and packaging behavior. GraalVM documents the underlying dynamic-feature and reachability rules.
Start with a Decision Record, Not a Build Plugin
Before changing the build, write down the problem and the acceptance criteria. A useful record contains measurable statements:
Problem:
Scale-to-zero workers miss the 750 ms readiness target.
Current JVM baseline:
p50 startup: 2.8 s
p95 startup: 3.4 s
idle RSS after 10 minutes: 310 MiB
steady p95 latency at 100 rps: 42 ms
Native acceptance criteria:
p95 startup below 750 ms
idle RSS below 180 MiB
steady p95 latency no worse than 50 ms
image build below 15 minutes
all integration and native smoke tests pass
The numbers above are examples, not expected GraalVM results. Your dependency graph, garbage collector, container limit, CPU architecture, CDS settings, traffic shape, database pool, and framework initialization all affect the outcome.
Native Image is a promising candidate when:
- instances are created frequently and readiness latency matters;
- the service scales to zero or runs as a short-lived job;
- memory density is a real infrastructure constraint;
- cold starts directly affect a request or event-processing objective;
- the application uses a reasonably static dependency graph.
It is less compelling when:
- the service runs continuously for weeks and is already warm;
- peak throughput is more important than cold start;
- build duration is already a delivery bottleneck;
- the application relies heavily on runtime plugins, scripting, agents, or dynamic class loading;
- memory is dominated by an in-process cache, large buffers, or application data rather than the runtime;
- the team cannot run native integration tests in CI.
Create a Reproducible Baseline
Use the same application, configuration, external services, CPU limit, memory limit, and traffic for both variants. Do not compare a locally warmed JVM to a native container on a different machine.
For a Maven project, a minimal baseline can use Spring Boot's parent and AOT support:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath />
</parent>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
Keep framework and plugin versions under dependency management where possible. Copying unrelated version numbers from an old example is a common source of native-build failures.
First prove that the ordinary artifact works:
./mvnw clean verify
java -jar target/application.jar
Then exercise the AOT-processed application on the JVM:
./mvnw spring-boot:process-aot package
This intermediate step is valuable because it separates Spring AOT problems from native compiler problems.
Choose One of Two Supported Build Paths
Path 1: Native executable with the build tools plugin
If a GraalVM JDK is installed and JAVA_HOME points to it:
./mvnw -Pnative native:compile
./target/application
This produces an executable for the current operating system and architecture. Native Image does not provide general cross-compilation, so a Linux AMD64 production binary should normally be built in a matching Linux environment.
Path 2: OCI image with Cloud Native Buildpacks
If Docker is available:
./mvnw -Pnative spring-boot:build-image \
-Dspring-boot.build-image.imageName=example/inventory-native:0.1.0
docker run --rm -p 8080:8080 \
example/inventory-native:0.1.0
This path avoids maintaining a hand-written builder image and packages the result in an OCI image. It is often the simpler CI option. Spring Boot also documents how an AOT-processed JAR can be converted later, which is useful when an organization wants to preserve an OS-neutral artifact before producing platform-specific images.
Avoid an improvised Alpine runtime unless the binary was deliberately built for musl and the required native libraries, certificates, time-zone data, fonts, and DNS behavior have been tested. A small image that cannot validate TLS certificates or render a required font is not an optimization.
The Compatibility Boundary: Reachability Metadata
Code that is directly reachable is straightforward. Dynamic Java behavior is harder:
Class.forNamewith a value read from configuration;- reflection over constructors or fields;
- JDK dynamic proxies;
- serialization that constructs types reflectively;
- JNI;
- resources loaded by a generated path;
- service-provider implementations discovered at runtime.
Spring and many popular libraries contribute hints automatically. When application code introduces a dynamic edge, register the smallest required metadata instead of making an entire package reflectively accessible.
Suppose an export format maps a configured name to an implementation:
public interface ReportRenderer {
byte[] render(Report report);
}
public final class PdfReportRenderer implements ReportRenderer {
public PdfReportRenderer() {}
@Override
public byte[] render(Report report) {
return report.toString().getBytes(StandardCharsets.UTF_8);
}
}
If the class is selected dynamically, add a runtime hint:
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportRuntimeHints;
@Configuration(proxyBeanMethods = false)
@ImportRuntimeHints(ReportRuntimeHints.class)
class ReportConfiguration {
}
final class ReportRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.reflection().registerType(
PdfReportRenderer.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS
);
hints.resources().registerPattern("templates/reports/*.mustache");
}
}
Only register what the application actually invokes. Broad hints increase image size, weaken the closed-world benefit, and can hide an architecture that is more dynamic than the native target can comfortably support.
The GraalVM Reachability Metadata Repository supplies metadata for many third-party libraries. For gaps, the tracing agent can observe dynamic behavior while the application runs on the JVM. Agent output is evidence from the paths that were exercised, not proof of completeness. Run representative integration tests and inspect the generated metadata before committing it.
Test the Artifact You Will Deploy
Passing JVM tests is necessary but insufficient. A missing resource or reflection entry may appear only in the native executable.
A practical test pyramid is:
- fast unit tests on the JVM;
- Spring integration tests on the JVM;
- native tests for application-context and serialization boundaries;
- a container smoke test against real backing services or disposable test containers;
- a short load test under production-like limits.
With the native Maven plugin:
./mvnw -Pnative native:test
At minimum, the native smoke suite should exercise:
- every controller serialization shape;
- validation and exception mapping;
- database migrations and representative repository queries;
- authentication, JWT parsing, and TLS;
- scheduled jobs and messaging listeners;
- resource loading, templates, localization, and time zones;
- health and readiness endpoints;
- graceful shutdown.
Test negative paths too. A native image that serves the happy-path endpoint but fails to deserialize an error payload is not production ready.
Measure Without Fooling Yourself
Startup
Measure from process creation until the readiness endpoint returns success, not until the first log line appears. Repeat cold starts, discard warm filesystem-cache assumptions only when they do not match production, and report percentiles rather than the best run.
Memory
Use process RSS or container working-set metrics after a defined idle period and during a defined load. Heap-only measurements are not comparable because native and JVM runtimes account for memory differently.
Throughput and latency
Warm the JVM according to its real lifecycle. If production instances live for days, comparing only the first ten seconds exaggerates startup and hides steady-state behavior. Record CPU, error rate, request latency, garbage collection, and downstream saturation together.
Build and delivery cost
Measure native compiler time, CI CPU and memory, cache hit behavior, artifact size, vulnerability scanning, and the delay added to rollback. Native compilation can move cost from runtime to delivery. That can be an excellent trade, but it should be visible.
Store the test script and environment with the result:
runtime, commit, architecture, cpu_limit, memory_limit,
startup_p50, startup_p95, idle_rss, loaded_rss,
steady_rps, latency_p95, error_rate, build_seconds
Without those fields, “80% less memory” is marketing, not an engineering result.
Common Failure Modes
The build succeeds but a request fails
This usually points to a dynamic path that the build never saw: reflection, a missing resource, a proxy, serialization metadata, or an optional dependency. Reproduce it with a focused native test, inspect the stack trace, then add the narrowest hint.
It works locally but not in the container
Check the target architecture, libc, CA certificates, DNS, locale, time-zone data, file permissions, and native libraries. The executable is platform-specific; “standalone” does not mean independent of every operating-system facility.
Startup is fast but readiness is still slow
Database connection pools, migrations, remote configuration, secrets, schema registry calls, and identity-provider discovery can dominate readiness. Native compilation cannot remove network latency. Decide which initialization must block readiness and which can occur lazily without accepting traffic too early.
Memory barely changes
Inspect what owns the memory. Large caches, Netty buffers, loaded datasets, database pools, and request concurrency can dominate both variants. Native Image mainly changes the runtime and reachable code; it does not shrink business data.
Throughput regresses
A warmed JVM can outperform an ahead-of-time executable on some long-running, CPU-heavy workloads. Recheck CPU limits, garbage collectors, compiler options, and representative traffic. If steady-state throughput is the governing objective, the JVM may simply be the correct target.
Operational Checklist
Before promoting a native image:
- The business reason and acceptance thresholds are recorded.
- JVM and native variants use identical dependencies and configuration.
- The binary is built for the production OS and architecture.
- JVM, native, integration, and negative-path tests pass.
- Required reflection, resources, proxies, and serialization types are explicit.
- TLS certificates, DNS, locale, and time-zone behavior are verified.
- Readiness measures actual service readiness.
- Startup, RSS, CPU, throughput, latency, errors, and build duration are compared.
- Logs, metrics, traces, heap or memory diagnostics, and crash artifacts are usable.
- The rollback path to the JVM artifact is documented.
A Sensible Adoption Strategy
Do not convert an entire estate because one demo starts quickly.
Start with one worker, CLI, scheduled job, scale-to-zero API, or small stateless service. Keep the ordinary JAR build in the pipeline. Add native tests and a repeatable benchmark. Run the candidate in production with a limited traffic slice, then compare operational data over enough time to include restarts, certificate rotation, dependency failures, and incident diagnostics.
If the native target meets a real objective and the compatibility burden stays controlled, expand it deliberately. If the JVM already meets the service-level objective, keeping the simpler and more familiar runtime is a successful engineering decision too.
References
- Spring Boot: GraalVM Native Images
- Spring Boot: Advanced Native Image Topics
- GraalVM: Dynamic Features of Java
- GraalVM: Reachability Metadata
The native-image decision is ultimately an operational decision. The strongest result is not the smallest executable; it is a deployment target whose benefit is measured, whose constraints are understood, and whose failures are covered by tests.