TL;DR: We migrated a classic Spring Boot microservice to Spring Boot 3 + Java 21 + GraalVM Native Image + Virtual Threads. We dropped cold starts from 3.4s to 75ms and cut memory usage by 4x — without rewriting the world. Here's how we did it.


A few months back, our invoice-service was what you'd call "classic enterprise Java"... it worked fine, until it didn’t.

Many alerts because of too long startup times. Memory usage kept creeping up. Scaling wasn't instant. We were paying for warm-up time on Kubernetes like it was 2015.

We knew something had to change.

This is the story of how we made that service native, fast, and cloud-ready.


The Service: What We Started With

invoice-service handles:

  • Invoice persistence (via JPA)
  • PDF generation (Apache PDFBox)
  • Calling an external payment API
  • Sending out emails

Basically: I/O, I/O, and more I/O.

Here’s the old stack:

  • Java 11
  • Spring Boot 2.7
  • RestTemplate
  • Fat JAR Docker build (~250MB)
  • Cold start in K8s: ~3.4 seconds
  • Memory: ~220MB idle

It ran, sure — but it was sluggish. Not exactly cloud native friendly.


Java 21 + Spring Boot 3

We bit the bullet and upgraded. Honestly, the Jakarta EE namespace migration was more annoying than hard:

javax.persistence -> jakarta.persistence
javax.servlet -> jakarta.servlet

Painful? A little. But manageable.

Once we were on Spring Boot 3.2 + Java 21, we got:

  • Hibernate 6 with improved performance
  • Out-of-the-box observability
  • Support for virtual threads
  • AOT and native image support baked in

Already, startup dropped to ~2.1 seconds — not bad for zero code change.


Virtual Threads to the Rescue

Here’s where things got spicy.

We noticed that invoice creation was bottlenecked by thread pool saturation. It had to:

  1. Save to the DB
  2. Generate a PDF
  3. Call payment API
  4. Send an email

All mostly I/O. We had been using @Async + thread pools. It worked, but the tuning was fragile.

With Java 21’s virtual threads, we rewrote it like this:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<Invoice> saved = scope.fork(() -> invoiceRepository.save(request.toEntity()));
    Future<File> pdf = scope.fork(() -> pdfService.generatePdf(request));
    Future<Void> payment = scope.fork(() -> paymentClient.callApi(request));
    Future<Void> mail = scope.fork(() -> emailService.sendMail(request));

    scope.join();
    scope.throwIfFailed();

    return new InvoiceResponse(saved.resultNow(), pdf.resultNow());
}

No CompletableFuture, no custom ExecutorService. Just readable, parallel logic with safe cancellation and error propagation.

Under load testing, throughput jumped by ~40%, and CPU dropped ~15%.


Going Native with GraalVM

We always talked about GraalVM like it was a mythical beast: powerful, but dangerous. Turns out, with Spring Boot 3, it’s shockingly tame.

We added this to our build:

./mvnw -Pnative -DskipTests spring-boot:build-image

Result:

  • Native binary ~60MB
  • Cold start: ~75ms
  • RAM usage idle: ~48MB

Yes, you read that right — a real Java microservice, starting faster than some Go apps.

Deploying this in our cluster felt like cheating.


Observability Still Works in Native

Here’s the cool part: tracing, metrics, and logs kept working after the native build.

With Micrometer Tracing and OTEL, we added:

management:
  tracing:
    enabled: true
    sampling:
      probability: 1.0
    otlp:
      endpoint: http://otel-collector:4317

No code change.

We got:

  • DB spans
  • HTTP client/server spans
  • PDF generation timing
  • Distributed trace correlation across services

Spring handled everything. Even in native mode. That was a wow moment.


Before vs After

Metric Before (JVM) After (Native + VT)
Cold Start Time ~3.4s ~75ms
Memory Usage (idle) ~220MB ~48MB
Docker Image Size ~250MB ~80MB
Concurrency Performance Moderate Great (I/O-scaled)
Observability Manual setup Built-in w/ OTEL

Lessons Learned

  • Virtual threads rock for I/O-heavy services. We didn’t need to switch to WebFlux to scale.
  • GraalVM is production-ready now, especially with Spring Boot’s AOT engine.
  • Native apps don’t mean losing Spring magic — metrics, tracing, profiles… all still work.
  • If you’re using RestTemplate, just don’t. WebClient or nothing.
  • We didn’t rewrite the service. We just upgraded it intelligently.

Final Thoughts

Spring Boot 3 + Java 21 + GraalVM isn’t just “the latest thing.” It’s a better way to build Java services.

  • Smaller
  • Faster
  • Cloud-native by design

Back in the game to compete with Quarkus ;)