8 min readjava

Killing Java cold start: Project Leyden vs CRaC vs GraalVM Native Image

Java's cold-start tax finally has three good answers — and picking wrong is expensive. A decision guide to Project Leyden, CRaC, and GraalVM Native Image, from the architecture, developer, and DevOps angles.

On this page

# The Problem

Your autoscaler just made things worse. Traffic spiked, Kubernetes dutifully scheduled five new pods of your Spring Boot service, and for the next ninety seconds those pods sat there not serving traffic — booting the JVM, loading classes, warming up the JIT — while the existing pods absorbed a load surge they were scaled up specifically to relieve. By the time the new pods were actually fast, the spike had passed. You paid for five instances of latency and got a thundering-herd incident for your trouble.

This is Java’s cold-start tax, and for a decade it was the trump card in every “why we chose Go/Node for serverless” conversation. A JVM that takes a second to start and several more to reach peak throughput is a genuinely bad fit for scale-to-zero functions and aggressive autoscaling.

Here’s what changed: in the last year Java got three legitimate answers to this problem, each with a different shape. The good news is the cold-start argument against the JVM is now much weaker. The catch is that these three tools are not interchangeable, and reaching for the wrong one costs you build complexity, peak throughput, or operational headaches you didn’t sign up for.


# The Three Answers

  • Project Leyden (AOT cache) — an enhanced Class-Data-Sharing mechanism that stores fully loaded and linked classes (and, via JEP 515, JIT method profiles) in an .aot file produced by a training run. First features shipped across JDK 24 and 25; a fourth lands in JDK 26. Reported results: ~41% faster startup on Spring PetClinic, and Spring Boot demos going from ~1.1s to ~0.27s (roughly 4x).
  • CRaC (Coordinated Restore at Checkpoint) — checkpoint a fully warmed process to a memory snapshot, then restore from it near-instantly. The strongest fit for serverless, where you want a warm process in milliseconds.
  • GraalVM Native Image — ahead-of-time compile the whole app to a native binary. Fastest cold start and lowest memory, at the cost of a closed-world build.
There is no single 'fast start' button

These three solve the same symptom via completely different mechanisms — a warm class cache, a memory snapshot, and an AOT-compiled binary. Their trade-offs differ on peak throughput, build complexity, and operational constraints. The skill is matching the tool to the deployment shape, not picking a universal winner.


# The Architecture Perspective

The architectural question is what you’re optimizing and what you’re willing to give up to get it. Two axes matter: startup latency (how fast to first request) and peak throughput (how fast once warm). The JVM’s superpower has always been the second one — the JIT profiles your running code and optimizes the hot paths better than any AOT compiler can. Some cold-start solutions preserve that; others trade it away.

flowchart TD
    Q1{Scale-to-zero / bursty?} -->|yes| Q2{Can you manage<br/>snapshot hygiene?}
    Q2 -->|yes| CRAC["CRaC<br/>checkpoint/restore"]
    Q2 -->|no| NI["GraalVM<br/>Native Image"]
    Q1 -->|"no: long-running,<br/>autoscaled"| LEY["Leyden AOT cache"]
    NI -.->|"closed-world build,<br/>no JIT peak"| C1["reflection config cost"]
    LEY -.->|"keeps the JIT"| C2["standard JVM,<br/>low friction"]

    classDef crac fill:#d97706,color:#fff,stroke:#92400e;
    classDef ni fill:#dc2626,color:#fff,stroke:#991b1b;
    classDef ley fill:#059669,color:#fff,stroke:#065f46;
    classDef note fill:#475569,color:#fff,stroke:#334155;
    class CRAC crac;
    class NI ni;
    class LEY ley;
    class C1,C2 note;

The architectural sweet spots:

  • Native Image wins where cold start dominates and peak throughput is irrelevant — scale-to-zero functions, short-lived CLIs, event handlers. You give up the JIT peak and take on a closed-world build (reflection needs metadata) in exchange for the fastest start and smallest footprint.
  • CRaC wins for serverless that wants a warm process instantly — it restores a full memory snapshot, so you skip both startup and warmup. The cost is snapshot-friendly code and snapshot hygiene (more on that below).
  • Leyden’s AOT cache is the low-friction default for long-running, autoscaled microservices — the autoscaler-thundering-herd case from our opener. You want both fast scale-out startup and sustained JIT-optimized throughput, and you don’t want to maintain reflection config or snapshot hygiene. Leyden keeps the standard JVM, keeps the JIT, and just makes startup and warmup dramatically faster.
Design so you can swap the strategy per service

These aren’t mutually exclusive across your fleet — and the JEPs plus Spring AOT are converging. Keep the startup strategy a deployment-level choice, not a code-level assumption, so a service can move from “plain JVM” to “AOT cache” to “native” without a rewrite as its deployment shape changes.


# The Developer Perspective

Here’s what each one costs you to actually adopt.

Leyden AOT cache — record a training run, then launch with the cache. Lowest friction of the three:

# 1) Record: run a representative training workload to produce the AOT cache.
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar

# 2) Create the cache from the recording.
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
     -XX:AOTCache=app.aot -jar app.jar

# 3) Run with it — faster startup, faster warmup, standard JVM.
java -XX:AOTCache=app.aot -jar app.jar

No reflection config, no snapshot, works on any OS and any GC. Spring AOT amplifies the effect.

CRaC — checkpoint a warmed process, restore instantly. The developer cost is handling open resources across the checkpoint:

// Resources must release on checkpoint and re-open on restore.
public class DbPool implements Resource {
    public void beforeCheckpoint(Context<?> ctx) { pool.close(); }   // release sockets
    public void afterRestore(Context<?> ctx)     { pool = reopen(); } // re-open + re-seed
}

Native Image — AOT-compile the whole app. Fastest start, but the closed-world model means reflection, proxies, and resources need hints (Spring/Quarkus generate most for you):

# Spring Boot AOT + GraalVM native build. Reflection needs reachability metadata.
./mvnw -Pnative native:compile
./target/app          # starts in tens of milliseconds; no JIT peak
Leyden is the lowest-friction win

If you’re not specifically chasing sub-100ms serverless starts, start with the Leyden AOT cache: one training run, no reflection config, no snapshot, and you keep the JIT. It’s the least disruptive way to cut both startup and warmup on a standard JVM.

Native Image's closed world has a build cost

Reflection, dynamic proxies, and resource loading must be known at build time via hints. Frameworks generate a lot of them, but you’ll still hit gaps — and native builds are slow, so budget the CI time and expect some “works on JVM, fails on native” debugging.


# The DevOps Perspective

Cold start is fundamentally a DevOps and cloud-cost story, so this is where the choice pays off — or bites.

The cloud-cost math is direct. Faster startup means a smaller warm pool and less autoscaler headroom, which is real money. It also means autoscaling works — new pods serve traffic before the spike ends, instead of arriving late like our opener. Model the savings: time-to-first-request × instances × how often you scale.

gantt
    title Time-to-first-request (illustrative)
    dateFormat X
    axisFormat %s
    section Plain JVM
    startup + warmup :0, 1100
    section Leyden AOT
    startup + warmup :0, 270
    section Native Image
    startup :0, 60
    section CRaC restore
    restore :0, 40

Each option changes your pipeline differently:

  • Native Image adds a slow, resource-hungry native build stage to CI and produces a platform-specific binary — plan build infra and per-arch builds.
  • CRaC makes the snapshot a build artifact you produce, store, and restore — a new thing in your pipeline with its own lifecycle and security posture.
  • Leyden adds a training-run step that produces the .aot file — the simplest pipeline change, and it runs on the standard JDK you already ship.

Snapshot hygiene is a security concern (CRaC). A CRaC snapshot captures memory — including secrets and RNG state. On restore you must re-seed entropy and re-open connections, and you must treat the snapshot as sensitive. Leyden sidesteps this entirely: no memory snapshot, nothing sensitive to protect. That’s a real operational argument for Leyden as the default when sub-100ms isn’t a hard requirement.

Pros

  • Leyden: lowest-friction, keeps the JIT, no snapshot, any OS/GC
  • CRaC: near-instant warm restore, ideal for serverless
  • Native Image: fastest start, smallest memory footprint
  • Faster startup shrinks warm pools and cuts cloud cost
  • Startup strategy can be a per-service deployment choice

Cons

  • Native Image: closed-world build, reflection hints, no JIT peak, slow CI
  • CRaC: snapshot hygiene — secrets/entropy — plus code changes
  • Leyden: needs a representative training run to pay off
  • Each adds a different new stage to your build pipeline
  • Wrong choice trades away throughput or adds ops burden

# Putting It Together

Java’s cold-start problem went from “an argument for leaving the JVM” to “a solved problem with three good answers” in about a year. There’s no universal winner: Native Image for scale-to-zero where throughput doesn’t matter, CRaC for serverless that wants a warm process instantly, and Leyden’s AOT cache as the low-friction default for long-running autoscaled services that want fast scale-out and the JIT’s peak throughput. Match the tool to the deployment shape, keep the choice at the deployment layer, and the autoscaler-made-it-worse incident stops happening.


# References

views

Click to show appreciation

Discussion