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:
- Save to the DB
- Generate a PDF
- Call payment API
- 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 ;)