in

GraalVM Native Image with Spring Boot: When Is It Worth It?

'The application starts in 60 milliseconds' is impressive; what does it cost? We compare GraalVM native image with the JVM and Java 25's AOT cache on startup, memory, peak throughput and compatibility; which scenarios really justify it, the step-by-step build with Spring Boot, RuntimeHints and the tracing agent, pitfalls such as profiles fixed at build time, and a decision guide.

Startup time and memory comparison chart for the JVM, the CDS/AOT cache and GraalVM native image

"The Spring Boot application starts in 60 milliseconds and runs on 80 MB of memory." This sentence from GraalVM native image talks is true and impressive. The part not said in the talk is also true: the build takes 5–10 minutes and 8 GB of RAM, every library that uses reflection needs metadata, peak throughput stays behind the JIT-powered JVM, and some decisions such as profiles are fixed at build time. Native image isn't magic, it's a trade; for enterprise teams the question isn't "can we do it" but "for which service is it worth it".

This article covers what native image does, the comparison with the JVM and Java 25's AOT cache, the scenarios where it pays off and where it doesn't, the step-by-step build with Spring Boot, RuntimeHints and the tracing agent, the problems met in the field and a decision guide.

What does native image do?

GraalVM native image analyzes the application and its dependencies at build time (the "closed world" assumption: no new classes are loaded at runtime), compiles everything reachable to machine code and produces a single executable that runs without a JVM. Class loading, bytecode verification and JIT warm-up disappear; startup drops to milliseconds, memory shrinks. The price is that dynamic behavior (reflection, dynamic proxies, resource loading, serialization) must be declared at build time. Spring Boot 3 resolves bean definitions at build time with the Spring AOT engine and generates most of the metadata the framework needs automatically; the rest is the responsibility of the application and third-party libraries.

Three options side by side

Criterion

JVM (JIT)

JVM + CDS / AOT cache (Java 25)

GraalVM native image

Startup (mid-size Spring Boot service)

8–20 s

3–8 s

0.05–0.3 s

Memory (RSS, idle)

300–600 MB

250–500 MB

60–150 MB

Peak throughput

Highest (profile-guided JIT optimization)

Same

10–40% lower (PGO closes the gap; Oracle GraalVM)

Warm-up

Minutes

Minutes

None; the first request at full speed

Build time / resources

Seconds

Seconds + a training run

3–15 min, 6–12 GB RAM

Compatibility

Full

Full

Closed world: metadata for reflection/proxies/resources; some libraries unsupported

Observability

JFR, profilers, agents, heap dumps

Same

Limited JFR, no agents; Micrometer/OTel work

GC

G1/ZGC/Shenandoah

Same

Serial (Community), G1 (Oracle GraalVM)

Debugging

Easy

Easy

Hard; failures at build time or "class not found" at runtime

The critical point: with the AOT cache (Leyden) in Java 24–25, JVM startup has already shortened noticeably. For most enterprise services "10 seconds → 4 seconds" is enough improvement and doesn't require paying native image's compatibility price (see Java 25).

When it pays off, and when it doesn't

Worth it

Not worth it

Scale-to-zero environments: AWS Lambda, Cloud Run, Knative — every cold start is user-facing latency

Long-running, high-throughput services: the JIT wins at peak, startup happens once a day

Many small services with low traffic: memory rent drops, pod density rises

Applications heavy on reflection/dynamic libraries (old ORMs, script engines, some SAP/JNI bridges)

CLI tools and short-lived jobs: the user waits for startup

Teams with limited CI capacity: every build costs minutes and gigabytes

Edge/IoT and constrained memory

Operations that depend on deep profiling and agents (APM agents, heap analysis)

Services needing fast horizontal scaling and instant rollback

Large monoliths with a high rate of change and many third-party dependencies

Step by step with Spring Boot

<!-- pom.xml: the Spring Boot 3.x parent already includes AOT support; the native profile adds the GraalVM plugin -->
<profiles>
  <profile>
    <id>native</id>
    <build>
      <plugins>
        <plugin>
          <groupId>org.graalvm.buildtools</groupId>
          <artifactId>native-maven-plugin</artifactId>
          <configuration>
            <buildArgs>
              <buildArg>--no-fallback</buildArg>
              <buildArg>-H:+ReportExceptionStackTraces</buildArg>
            </buildArgs>
          </configuration>
        </plugin>
      </plugins>
    </build>
  </profile>
</profiles>
# 1) Local build with a GraalVM JDK (Oracle GraalVM or GraalVM Community / Liberica NIK)
./mvnw -Pnative native:compile
./target/order-service                        # up in 60–200 ms

# 2) A container image with Buildpacks, without installing GraalVM
./mvnw -Pnative spring-boot:build-image       # BP_NATIVE_IMAGE=true; image ~100 MB, no JVM required

# 3) Native tests: the JUnit tests run inside the native executable
./mvnw -PnativeTest test

Hints for reflection and resources

// Types serialized with Jackson that Spring can't see (e.g. DTOs only loaded dynamically)
@RegisterReflectionForBinding({InvoiceDto.class, SapReturnRecord.class})
@Configuration
class SerializationConfig {}

// More general: reflection, resource and proxy registrations through a RuntimeHintsRegistrar
@ImportRuntimeHints(IntegrationHints.class)
@Configuration
class IntegrationConfig {}

class IntegrationHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader cl) {
        hints.reflection().registerType(LegacyClient.class, MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.DECLARED_FIELDS);
        hints.resources().registerPattern("templates/*.xml");
        hints.proxies().registerJdkProxy(OrderPort.class);
        hints.serialization().registerType(TypeReference.of("com.acme.legacy.LegacyRecord"));
    }
}

Instead of finding by hand what each library needs, use the tracing agent: the application runs on the JVM with the agent, tests and typical flows are exercised, and the agent writes reflection/resource/proxy usage as JSON under META-INF/native-image/. GraalVM's reachability metadata repository provides ready-made metadata for common libraries; Spring's own hints come automatically. The registrations above cover the remaining gaps.

java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image/acme/order-service \
     -jar target/order-service.jar            # then trigger all flows and the integration tests

Problems met in the field

  1. Profiles and conditions are fixed at build time. Spring AOT decides @Profile and @Conditional during the build; an image built with the prod profile can't load different beans under the test profile. Configuration values that vary by environment (URLs, secrets) are read at runtime; but the bean set is determined at build time. You need an image per environment or a profile-independent design.

  2. "Class not found" at runtime: Reflection without metadata. Caught before the build with the tracing agent and native tests; every one caught in production means a release.

  3. Unsupported libraries: JNI-based bridges (such as SAP JCo), some script engines, old dynamic-proxy libraries. A service with such a dependency stays on the JVM.

  4. The memory illusion: RSS drops but the heap requirement stays the same; -Xmx is still set. The Community edition has Serial GC; pauses grow at high allocation rates.

  5. Build infrastructure: 8–12 GB of RAM on CI runners; build time of minutes per PR. Native builds usually only for the main branch and releases, JVM tests on PRs.

  6. The observability gap: APM agents don't work; the OpenTelemetry SDK and Micrometer do. Not adopted before the operations team's tools are checked (see observability).

  7. The peak-throughput surprise: The JVM coming out 20% ahead in a load test is normal; PGO (profile-guided optimization) on Oracle GraalVM closes the gap but needs an extra training step.

Decision guide

  • Does the service scale to zero, or does cold start affect the user experience? If not, try the Java 25 AOT cache first; it's probably enough.

  • Is the dependency list native-compatible? A one-day trial with the tracing agent and native tests answers that.

  • Can the operations tools (profiling, agents, heap dumps) be covered on native?

  • Are the CI build capacity and duration acceptable?

  • Is peak throughput critical? If yes, the JVM; or Oracle GraalVM + a PGO measurement.

In practice the typical enterprise landscape is mixed: functions, CLI tools and rarely called small services native; core transactional services JVM + AOT cache. The same codebase can be built both ways; once native compatibility is achieved, the choice is made per service.

Frequently asked questions

Is CRaC an alternative?

Yes, with a different trade: CRaC (Coordinated Restore at Checkpoint) takes a snapshot of the warmed-up JVM and restores it in under a second; full JVM compatibility is preserved and the JIT gains remain. The price: Linux-only, checkpoint discipline (closing open connections and files; supported by Spring Boot 3.2+), and the snapshot may contain secrets. If compatibility is a problem but startup is critical, try CRaC before native.

Oracle GraalVM or Community?

Community (and distributions such as Liberica NIK) is free and enough for most scenarios; Oracle GraalVM offers G1 GC, PGO and better peak performance, with license terms (GFTC) to be evaluated for production use.

Does native image make a difference for security?

The attack surface shrinks (no JVM or class loading, closed world), but dependency vulnerabilities are compiled in as they are; SBOM and scanning are still needed. The executable can't be patched without a rebuild; the patch process is an image rebuild.

Conclusion

GraalVM native image is an extraordinary tool in the right place: scale-to-zero services, functions, CLI tools and dense clusters of small services. In the wrong place — high-throughput core services, reflection-heavy dependencies, limited CI — its cost exceeds its gain. Java 25's AOT cache delivers "fast enough startup" for most enterprise services without native; the native decision is made by measurement, per service, after a one-day trial with the tracing agent.

To measure your services' startup and memory profile, evaluate native compatibility or move the right services to native, see our software design and architecture service.