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.

Stefan Kehayov

Full-stack engineer · Dospat · Bulgaria