Skip to content
nullchefo
← Back to journal
2 min read

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.

#java#spring#backend Also available in: Български

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.