What 3 Months of GraalVM Native Image Taught Me About Rust's Real Advantage

📅 May 10, 2026
What 3 Months of GraalVM Native Image Taught Me About Rust's Real Advantage
👁 ... views

I’ll be honest: after writing about switching to Rust for performance-critical services, the number one objection I got wasn’t about the borrow checker or the learning curve. It was this: “Why not just use GraalVM?”

And you know what? It’s a fair question. If your team already runs Spring Boot, and GraalVM Native Image gives you sub-100ms startup and 75% less memory — why rewrite anything in Rust?

I spent the last three months running GraalVM Native Image in production alongside our Rust services. I wanted to answer one thing: does GraalVM make Rust unnecessary for Java teams?

The answer is more nuanced than both the GraalVM press releases and the Rust evangelists want you to believe.

Why I Bothered Testing GraalVM at All

After my Rust article (which, to be clear, compared Rust against the JVM version of Spring Boot), several senior Java devs called me out: “You didn’t even mention GraalVM. Of course Rust wins against a JVM with a 3-second cold start.”

They were right. It was a gap. So I set up a GraalVM Native Image build for one of our Spring Boot microservices — an order processing API with PostgreSQL, Jackson serialization, and Spring Data JPA. The same service we’d been running on the JVM for two years.

Here’s what I found when I put it through the same load tests I ran against Rust.

The Numbers: GraalVM vs Rust vs JVM

I ran the same benchmark across all three: a simple JSON API endpoint with a PostgreSQL query, 100 concurrent connections, 60-second sustained load.

MetricSpring Boot (JVM)Spring Boot (GraalVM Native)Rust (Axum + sqlx)
Startup time3,200ms45ms3ms
Memory (idle)280 MB55 MB12 MB
Memory (under load)480 MB180 MB28 MB
Throughput4,200 req/s3,600 req/s42,000 req/s
P99 latency45ms38ms3ms
GC pauses5–50ms1–3ms0ms
Build time15s12 min45s

GraalVM’s wins over the JVM are undeniable. Startup went from 3.2 seconds to 45 milliseconds. Memory dropped from 280 MB to 55 MB at idle. If your only problem was cold starts and memory footprint, GraalVM solves it.

But look at what GraalVM doesn’t solve: throughput actually dropped 14% compared to the warmed JVM (3,600 vs 4,200 req/s). That’s the AOT trade-off — you lose JIT’s runtime optimization. And compared to Rust? Rust processes 10x the requests with 5x less memory under load.

What GraalVM Actually Gets Right

I want to give GraalVM its due, because the improvements are real:

The Spring AOT processor works better than I expected. Spring Boot 3.x generates static bean instantiation code at build time — no runtime reflection for your standard @Component and @Bean definitions. For a vanilla Spring Boot app, you barely need to configure anything.

# That's it. One flag for most Spring Boot apps.
./mvnw -Pnative native:compile

The build produces a standalone binary — no JVM required at runtime. Container image went from 320 MB to 85 MB. For Kubernetes deployments, that matters. Pod scheduling is faster, and when HPA scales from 2 to 20 pods, they’re ready in under a second instead of waiting 3 seconds each.

The cost savings are real. On AWS Fargate, moving our JVM services to GraalVM Native Image cut our monthly bill by roughly 60%. We went from running 20 instances at 512 MB each to 20 instances at 128 MB. That’s about $4,800/month saved for a single service.

But here’s where things get interesting.

Where GraalVM Made My Life Harder

The Build Time Tax

Every native image build takes 10–15 minutes. Compare that to a Rust build (45 seconds for our service) or a standard Spring Boot JAR build (15 seconds).

In CI, this means you can’t run native image builds on every PR. We set up a separate CI job that only runs after tests pass:

# Our CI: fast feedback first, native build later
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./mvnw test  # 45 seconds

  native-build:
    needs: test
    runs-on: ubuntu-latest
    resources:
      memory: 8Gi  # Yes, it needs 8GB
    steps:
      - uses: actions/checkout@v4
      - run: ./mvnw -Pnative native:compile  # 12 minutes

With Rust, the build is the test. cargo build takes 45 seconds and produces a production binary. No separate pipeline stage, no resource-heavy builder.

The Reflection Nightmare

Here’s the GraalVM reality check that no one talks about: the closed-world assumption breaks the moment you touch dynamic code.

We hit our first wall with Jackson. A custom deserializer that worked perfectly on the JVM threw ClassNotFoundException on native image. Why? Because Jackson uses reflection to discover modules at runtime, and GraalVM doesn’t know about them at build time.

The fix required a RuntimeHintsRegistrar:

@Configuration
@ImportRuntimeHints(JacksonRuntimeHints.class)
public class JacksonConfig { }

class JacksonRuntimeHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        hints.reflection().registerType(
            CustomDeserializer.class,
            MemberCategory.INVOKE_DECLARED_METHODS
        );
        hints.resources().registerPattern("META-INF/services/*");
    }
}

Then we hit it again with Spring Data JPA proxy generation. Then with our health check endpoints. Then with a logging library that used dynamic class loading.

The GraalVM tracing agent helps:

java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
     -jar target/order-api.jar

Run your integration tests, let the agent record what reflection/resources/proxies you need, and rebuild. But this is a dev/prod parity problem — your app behaves differently during development (JVM) than in production (native image). You won’t catch these issues until the native build, which takes 12 minutes.

Rust doesn’t have this problem because there’s no runtime discovery. If it compiles, it works. The borrow checker catches issues at compile time, not at a 12-minute build step.

The Throughput Regression

This was the most surprising finding. Under sustained load, GraalVM Native Image actually processed 14% fewer requests per second than the warmed JVM (3,600 vs 4,200 req/s).

The reason: GraalVM’s AOT compilation produces a static binary. The JVM’s JIT compiler continuously optimizes hot code paths at runtime. For short-lived serverless functions, AOT wins — there’s no time to warm up. But for a long-running service that handles millions of requests, the JVM’s adaptive optimization eventually pulls ahead.

Rust, compiled through LLVM, gets the best of both: AOT compilation with zero-cost abstractions that are as fast as hand-written C. No JIT warmup needed, no runtime overhead, and 10x the throughput of both JVM and GraalVM.

What I Actually Run in Production Now

After three months, here’s my honest breakdown:

GraalVM Native Image for: Internal tools, batch jobs, CLI apps, and services where cold starts matter (serverless, HPA-heavy Kubernetes). The cost savings on infrastructure are genuine, and the Spring ecosystem compatibility means our Java team doesn’t need to learn a new language.

JVM (warm) for: Long-running services with complex reflection needs, heavy compute workloads where JIT optimization matters, and anything that uses libraries GraalVM doesn’t support natively. If your service runs 24/7 and rarely restarts, the JVM’s warm performance is actually better than GraalVM.

Rust for: Performance-critical paths — API gateways, real-time event processing, message brokers, and anything where P99 latency matters. When you need P99 < 5ms, GraalVM’s 38ms P99 won’t cut it.

┌─────────────────┬──────────────┬───────────────┬──────────────┐
│ Decision Factor │   GraalVM    │     JVM       │    Rust      │
├─────────────────┼──────────────┼───────────────┼──────────────┤
│ Startup < 100ms │  ✅ 45ms     │  ❌ 3,200ms   │  ✅ 3ms      │
│ Memory < 50MB   │  ❌ 55MB     │  ❌ 280MB     │  ✅ 12MB     │
│ Throughput      │  ⚠️ 3,600/s  │  ✅ 4,200/s   │  ✅ 42,000/s │
│ P99 < 10ms      │  ❌ 38ms     │  ❌ 45ms      │  ✅ 3ms      │
│ Dev Experience  │  ⚠️ 12min    │  ✅ 15s       │  ✅ 45s      │
│ Ecosystem       │  ✅ Full Java│  ✅ Full Java │  ⚠️ Growing  │
│ Team Skill Req  │  ✅ Java     │  ✅ Java      │  ⚠️ Rust     │
│ Build CI Cost   │  ❌ Heavy    │  ✅ Light     │  ✅ Light    │
└─────────────────┴──────────────┴───────────────┴──────────────┘

The GraalVM Objections I Didn’t Expect

Some will say GraalVM is “good enough” — and for many workloads, it absolutely is. If your Spring Boot API handles 500 req/s and restarts twice a week, GraalVM is overkill. If it’s serverless with cold start SLAs, GraalVM is the pragmatic choice.

But here’s what I learned that surprised me:

Project Leyden is coming. The OpenJDK project is working on bringing AOT compilation benefits directly into standard Java. In 2–3 years, the gap between JVM and GraalVM may close significantly. If you’re considering a Rust rewrite today, ask whether waiting for Leyden makes more sense.

Quarkus Native outperforms Spring Boot Native. In the same benchmarks, Quarkus + GraalVM hit 5,100 req/s with 35 MB memory — better than Spring Boot Native (3,600 req/s, 55 MB). If you’re going the Java-native route and starting greenfield, Quarkus is worth evaluating. But for existing Spring Boot teams, the migration cost is real.

The real Rust advantage isn’t startup speed. It’s consistency. Every request’s latency is determined purely by your code, not by when the GC decides to run. GraalVM reduces GC pauses from 5–50ms to 1–3ms — but Rust eliminates them entirely. For services where latency jitter matters (real-time trading, gaming, live APIs), that difference is everything.

What Went Wrong (So You Don’t Have To)

I lost two days debugging a UnsatisfiedLinkError in production because a native library our health check depended on wasn’t available in the debian:bookworm-slim runtime image. The native image built fine, passed all nativeTest checks, and deployed. Then the first health check hit and the pod crashed.

The fix was a multi-stage Dockerfile with the right native libraries:

FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /app
COPY . .
RUN ./mvnw -Pnative native:compile

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y libstdc++6 zlib1g
COPY --from=builder /app/target/order-api /app/order-api
ENTRYPOINT ["/app/order-api"]

With Rust, cargo build --release produces a statically-linked binary that works on any Linux distribution. No runtime library hunting. No multi-stage Dockerfile debugging. The binary just works.

My Verdict After 3 Months

GraalVM Native Image is the best performance upgrade you can give a Spring Boot application without changing languages. The startup time improvements are dramatic, the memory savings are real, and the cost reduction on cloud infrastructure is measurable.

But it doesn’t make Rust unnecessary. It makes Rust a different choice — not a replacement, but a complement.

Use GraalVM when: You’re a Java team, you have existing Spring Boot services, and your bottleneck is startup time or memory. You want 80% of the performance improvement with 20% of the effort.

Use Rust when: You’re building a new service where P99 latency, throughput, or memory footprint are the primary constraints. You have developers willing to invest 2–3 months learning the language. You need the consistency that only zero-GC provides.

Use both when: Your architecture has a mix of workloads. The order API? GraalVM. The real-time event gateway processing 50K events/second? Rust. The batch reporting job? JVM.

What 3 months of GraalVM taught me about Rust is this: Rust’s advantage isn’t that Java can’t be fast. It’s that Rust is consistently fast, everywhere, without configuration, without trade-offs, and without a 12-minute build step standing between you and production.

GraalVM closes the startup gap. Rust closes everything else.


💡

Enjoying the content? Here are tools I personally use and recommend:

  • 🌐 Hosting: Bluehost — what this blog runs on
  • 🛒 Tech Gear: My Amazon Store — keyboards, monitors, dev tools I use

Purchases through my links help keep this blog ad-free 💙

Enjoyed this post?

Subscribe to the newsletter or follow on YouTube for more dev content.

🎬 Watch Shorts