Spring Boot on Project Leyden: The AOT Cache Recipe That Actually Works

Spring Boot 4 supports the JDK 25+ AOT cache - and published benchmarks show 2-4x faster startup. The recipe, the numbers, and the one step (extract the JAR) that decides whether you get the win.

TL;DR: Spring Boot Gets the Leyden Treatment — With One Catch

  • What: Spring Boot 4.x supports the JDK 25+ AOT cache (Project Leyden) out of the box — a training run, one flag, and an extracted JAR layout
  • Impact: Independent measurements show roughly 55–75% faster startup: a JPA + Liquibase service from ~3 s to ~0.75 s, a Redis service from 1.1 s to 0.27 s, and a Docker-to-first-200 benchmark from 6.3 s to 2.3 s
  • The catch: The cache only works against the extracted application layout. Point it at a fat JAR and it silently does almost nothing
  • Stacks with: Spring’s own build-time AOT processing (spring.aot.enabled) — the two are different things and compound
  • Status: Supported in Spring Boot 4.x on JDK 25+; CDS remains the fallback on JDK 17/21

Why Spring Boot Needs This More Than Quarkus Does

The previous post covered Project Leyden through the Quarkus lens, where a 9,000-class CRUD app dropped from three seconds to under one. Quarkus, though, was already the fast-starting framework — it moved most of its bootstrap to build time years ago.

Spring Boot is where the cold-start pain actually lives in most enterprises. It does its wiring at runtime: classpath scanning, conditional auto-configuration, reflection-heavy bean creation, and a class-loading storm of several thousand classes before the first request. That is exactly the work Leyden’s AOT cache is designed to skip. The framework with the biggest startup bill has the most to gain.

And unlike the Quarkus integration, which is a single property, Spring Boot’s recipe has a step that trips people up. This post is that recipe, the numbers, and the trip-wires.

Two Different “AOT”s — Don’t Confuse Them

Spring has used the letters AOT since Spring Framework 6, and it means something different from the JDK feature:

  • Spring AOT processing (spring.aot.enabled=true) — a build-time step that resolves your @Configuration classes and conditions ahead of time and generates plain Java bean-registration code. It removes runtime reflection and scanning from the framework. This was built for GraalVM native image but works on a normal JVM too.
  • JDK AOT cache (JEP 483/514/515/516, Project Leyden) — a JVM-level archive of pre-loaded, pre-linked classes, heap objects and method profiles, produced by a training run. It knows nothing about Spring; it caches whatever classes the JVM loaded.

They are complementary. Spring AOT reduces how much bootstrap work exists; the JDK AOT cache makes whatever remains cheaper. Ralph Schaer’s April 2026 benchmark measured them together: the AOT cache alone took a service from 5.3 s to 2.7 s, and adding Spring AOT on top brought it to 2.3 s.

📌 Where CDS fits

Spring Boot has supported Class Data Sharing archives (-XX:ArchiveClassesAtExit) since 3.3, and the same -Dspring.context.exit=onRefresh training trick works for both. Spring’s documentation now recommends the AOT cache over CDS on Java 25+, because the cache also carries linked classes, heap objects and JIT profiles rather than class metadata alone. If you are pinned to JDK 17 or 21, CDS is still the tool — it gets you part of the way.

The Numbers

Two independent practitioners published Spring Boot measurements this year. Both used JDK 26 builds, both are directional rather than lab-grade, and both point the same way.

Piotr Minkowski — JPA, Liquibase, Actuator, H2 (March 2026)

  • Fat JAR, no cache: ~3.0 s
  • AOT cache against the fat JAR: ~2.0 s
  • AOT cache against the extracted layout, training with spring.context.exit=onRefresh: ~0.75 s

The middle row is the one to internalise. The cache “worked” against the fat JAR — it just delivered a third of the benefit. A second app (Web + Data Redis) in the same write-up went from 1.1 s to 0.27 s once the layout was right.

Ralph Schaer — wall-clock from docker run to first HTTP 200 (April 2026)

This is the more honest metric for Kubernetes, because it includes the container and JVM launch, not just Spring’s own stopwatch. Median of 15 runs on JDK 26:

  • Fat JAR: 6,281 ms | 181 MB image
  • Extracted layout only: 5,296 ms | 181 MB
  • Extracted + AOT cache: 2,699 ms | 211 MB
  • Extracted + Spring AOT + AOT cache: 2,326 ms | 209 MB
  • CRaC checkpoint/restore: 1,010 ms | 208 MB

Two things stand out. Simply extracting the JAR is worth a second on its own — the nested-JAR classloader is not free. And the cache costs about 30 MB of image, in line with the ~40 MB Quarkus reported.

CRaC is faster still, but it needs a Linux-only JVM build, a privileged container for the checkpoint, and an application that survives having its sockets and file handles frozen. For a bank’s platform team that is a different risk conversation entirely; Leyden asks for none of it.

The Recipe

Prerequisites

  • Spring Boot 4.x (the AOT cache section landed in the 4.x reference docs; the current release is 4.1)
  • JDK 25 or later — JDK 26 if you run ZGC, per JEP 516
  • A build that can run the application once, in a container, with no side effects

Step 1: Extract — this is the step everyone skips

java -Djarmode=tools -jar target/my-app.jar extract --layers --destination extracted

Spring Boot’s fat JAR loads nested JARs through its own LaunchedClassLoader. The AOT cache can only archive classes loaded from a plain, stable classpath, so it needs the exploded layout with real JAR files on disk. The --layers flag also splits dependencies from application code so Docker can cache the layers separately.

Step 2: The training run

cd extracted
java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar my-app.jar

spring.context.exit=onRefresh is Spring Boot’s contribution: the context fully refreshes — every bean instantiated, every auto-configuration evaluated, every class loaded — and then the JVM exits cleanly, writing the cache. You get a complete bootstrap profile without ever opening a port or serving a request.

Step 3: Run with the cache

java -XX:AOTCache=app.aot -jar my-app.jar

Putting it in a Dockerfile

This is the shape Spring’s own reference documentation recommends — the training run happens inside the runtime image so the cache is bound to exactly the JDK that will use it:

FROM bellsoft/liberica-openjre-debian:25-cds AS builder
WORKDIR /builder
COPY target/*.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

FROM bellsoft/liberica-openjre-debian:25-cds
WORKDIR /application
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./

RUN java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar application.jar

ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-jar", "application.jar"]

If you build with Cloud Native Buildpacks instead, the Paketo Java buildpack does all of this for you: set BP_JVM_AOTCACHE_ENABLED=true and, if the training run needs different settings, TRAINING_RUN_JAVA_TOOL_OPTIONS.

⚠️ Pro Tip: Your training run will try to talk to production

A Spring context refresh initialises datasources, Flyway/Liquibase, Kafka consumers, Redis clients and discovery registration. Inside a Docker build none of those endpoints exist, and some of them — Hikari’s connection validation, schema migration — fire before the lifecycle hooks that onRefresh waits for. Give the training run its own profile: spring.datasource.hikari.initialization-fail-timeout=-1, spring.liquibase.enabled=false, spring.kafka.listener.auto-startup=false, and lazy initialisation for anything that phones home. Spring’s lifecycle smoke-test repo has worked examples per starter.

Operational Realities

These are the same constraints as the Quarkus post, with Spring-specific edges:

  • The cache is bound to the JDK build, the architecture and the exact classpath. Bump a starter, regenerate the cache. Patch the base image, regenerate the cache. It is a build artifact, not something you commit.
  • Fail loudly in staging. A rejected cache falls back to a normal cold start and says nothing. Run -XX:AOTMode=on in non-production so a mismatch fails the pod instead of quietly costing you two seconds, and use -Xlog:aot to see what was actually mapped.
  • Verify the layout, not just the flag. The “it works but only 30% faster” symptom is nearly always a fat JAR. If your Kubernetes manifests or Helm charts hand-roll the java -jar command, check that they point at the extracted application.jar, not the uber-JAR from target/.
  • Profile-driven beans are not in the cache. Anything behind a @Profile that was inactive during training gets loaded the slow way at runtime. Train with the profile set you actually deploy with.
  • Layer reuse is gone for the cache layer. ~30 MB that changes on every build, pulled on every deploy. Budget it in registry bandwidth, not just disk.

When It Pays Off on Kubernetes

A Spring Boot service on a 2-CPU pod typically sits at 4–8 seconds from docker run to ready. Trimming that to ~2 seconds changes three things: the readiness-probe initialDelaySeconds can drop, which is usually the true bottleneck; HPA scale-out events deliver capacity noticeably sooner; and a rolling deployment of 100 pods spends far less cumulative time under-provisioned. A service that lives for days and is only ever restarted by a deployment sees none of that. Know which one you are.

⚠️ Spring Boot 3.x estates on JDK 17/21

Spring Boot 3.x is on a shrinking open-source support window, and the AOT cache needs JDK 25+. If you are planning the Boot 4 / JDK 25 move anyway, fold this in — it costs an extract step and a training run, and the cold-start win is one of the few upgrade benefits you can show a business stakeholder on a dashboard.

Verdict

For Spring Boot the AOT cache is the highest-leverage startup optimisation available without changing your runtime model. Native image asks you to give up the JIT and configure reflection; CRaC asks for a privileged checkpoint and a special JVM. Leyden asks for an extracted JAR, a training run, and one profile for the build — and hands back a 2× to 4× cold-start improvement on the framework that needed it most.

Extract the JAR. That is the whole trick.


Frequently Asked Questions

Does the AOT cache work with a Spring Boot fat JAR?

Only partially. The cache needs a plain classpath, and the fat JAR’s nested-JAR classloader defeats most of it. Spring’s documentation is explicit that the cache must be used with the extracted form of the application; measurements show roughly a third of the benefit otherwise.

Is the JDK AOT cache the same as spring.aot.enabled?

No. Spring AOT is build-time generation of bean-registration code that removes reflection and scanning. The JDK AOT cache archives loaded classes, heap objects and JIT profiles. They stack; enabling both gave the best JVM-mode result in published benchmarks.

What does -Dspring.context.exit=onRefresh do?

It tells Spring Boot to refresh the full application context — creating every bean and loading every class — and then exit the JVM before starting the web server. Combined with -XX:AOTCacheOutput this produces a complete cache without serving traffic.

Can I use this on Spring Boot 3 with JDK 21?

Not the AOT cache — it requires JDK 25+. Spring Boot 3.3+ supports CDS archives with the same training-run technique, which delivers a smaller but real improvement on JDK 17 and 21.

Leyden, native image or CRaC for Spring Boot?

Leyden for the default case: no code changes, full JVM, 2–4× faster startup. Native image when you need sub-100 ms and can live with the closed world. CRaC when you need the absolute fastest restore and can run privileged checkpoints on Linux.

Further Reading

Subscribe
Notify of

0 Comments
Oldest
Newest Most Voted