Virtual threads in Spring Boot, two years in
Notes from running Project Loom in production: where virtual threads genuinely simplify a Spring service, and the two traps that still bite.
When virtual threads landed as stable in JDK 21, the pitch was simple: write blocking code, get reactive-ish throughput. After running them in production Spring Boot services for a while, the pitch mostly holds — with caveats worth writing down.
Turning them on is the easy part
In Spring Boot 3.2+ it is a single property:
spring:
threads:
virtual:
enabled: true
With that flag, Tomcat handles each request on a virtual thread, @Async methods and task executors follow, and the classic thread-per-request model suddenly scales like an event loop. No Mono, no Flux, no colored functions.
Where it actually helped
Our services are typical integration glue: take a request, call two or three downstream APIs, hit MongoDB, aggregate, respond. Under load, platform threads used to be the bottleneck — 200 threads parked on I/O is 200 stacks of wasted memory.
With virtual threads the same blocking code looks like this and scales fine:
@GetMapping("/items/{id}/summary")
ItemSummary summary(@PathVariable String id) {
var details = detailsClient.fetch(id); // blocks, cheaply
var reviews = reviewsClient.fetch(id); // blocks, cheaply
return ItemSummary.merge(details, reviews);
}
The mental model your team already has — one request, one thread, readable stack traces — stays intact. That is the real win: we deleted a WebFlux service and replaced it with code juniors can debug.
The two traps
Pinning. A virtual thread that blocks inside a synchronized block pins its carrier thread, quietly eating your scalability. Older connection pools and drivers were full of this. Run with the detector on until you trust your dependencies:
java -Djdk.tracePinnedThreads=full -jar app.jar
Unbounded concurrency. Virtual threads make spawning cheap, which makes it easy to point a firehose at a downstream service that very much still has limits. The fix is the same as it always was — explicit backpressure:
var semaphore = new Semaphore(50);
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
for (var id : itemIds) {
scope.fork(() -> {
semaphore.acquire();
try {
return enrich(id);
} finally {
semaphore.release();
}
});
}
scope.join().throwIfFailed();
}
Virtual threads remove the cost of blocking, not the cost of the thing you are blocking on.
Would I default to them?
For new Spring services: yes. Start with spring.threads.virtual.enabled=true, keep the code blocking and boring, and reach for reactive only when you need streaming semantics rather than throughput. Boring code that scales is the best kind of code.