Spring Boot with Virtual Threads: Migration and Benchmarks
Back to Blog

Spring Boot with Virtual Threads: Migration and Benchmarks

6 min read
Read in Deutsch

Spring Boot with Virtual Threads: Migration and Benchmarks

When we introduced Java 21 last September, the anticipation for Virtual Threads in Spring Boot was palpable. With Spring Boot 3.2 (November 2023) and the recently released 3.3 (May 2024), this support is now a reality -- and after several months of productive use, we at encircle360 can draw an initial conclusion.

The result upfront: for I/O-heavy services, the switch was worth it. The path there was surprisingly easy -- and the pitfalls were where we least expected them.

One Property That Changes Everything

Enabling Virtual Threads in Spring Boot 3.2+ is remarkably straightforward:

# application.properties
spring.threads.virtual.enabled=true

That's it. One line. But what happens under the hood?

Once this property is set, Spring Boot changes several infrastructure components simultaneously:

  • Tomcat uses a virtual-thread-per-request executor for incoming HTTP requests instead of the classic thread pool
  • Async tasks (@Async) are executed on virtual threads
  • Scheduling (@Scheduled) also uses virtual threads
  • Spring MVC request handling benefits automatically, without code changes

The embedded Tomcat thus creates a new virtual thread for each incoming request -- no pool, no tuning, no more server.tomcat.threads.max configuration. The JVM handles scheduling onto the underlying carrier threads.

Prerequisites: Java 21 and Spring Boot 3.2+

Before setting the property, two prerequisites must be met:

  1. Java 21 as runtime -- Virtual Threads have been a final feature since Java 21, no longer a preview
  2. Spring Boot 3.2 or newer -- if you're still on the 3.0 or 3.1 line, you need to upgrade first

If you've already completed the Spring Boot 3 migration and are running on Java 21, you can activate Virtual Threads immediately. For everyone else, we recommend performing the Java 21 migration as a separate step -- not together with the Virtual Thread activation.

Benchmark: Platform Threads vs. Virtual Threads

We used a typical I/O-heavy service as a benchmark: a REST controller that executes a database query and an external HTTP call. Together, these calls generate about 100 ms of latency per request.

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderRepository orderRepository;
    private final RestClient restClient;

    public OrderController(OrderRepository orderRepository, RestClient.Builder builder) {
        this.orderRepository = orderRepository;
        this.restClient = builder.baseUrl("https://inventory.internal").build();
    }

    @GetMapping("/{id}")
    public OrderResponse getOrder(@PathVariable Long id) {
        // ~50 ms database query
        Order order = orderRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));

        // ~50 ms external HTTP call
        InventoryStatus status = restClient.get()
                .uri("/api/stock/{sku}", order.getSku())
                .retrieve()
                .body(InventoryStatus.class);

        return new OrderResponse(order, status);
    }
}

We stressed this endpoint with 2,000 concurrent connections over 60 seconds. The results:

Metric Platform Threads (200 max) Virtual Threads
Throughput (req/s) ~1,850 ~14,200
p50 Latency 108 ms 105 ms
p99 Latency 1,240 ms 142 ms
Error Rate 2.3% 0.0%

The p50 latency is nearly identical -- a single request doesn't get faster. But throughput multiplies because virtual threads don't get exhausted in a pool. The p99 latency drops dramatically because no requests have to wait in a queue for a free thread. And the error rate drops to zero because there are no more connection timeouts due to thread exhaustion.

Replacing WebFlux with Blocking Code

Perhaps the most exciting aspect of Virtual Threads: they make reactive code unnecessary for many use cases. In our Virtual Threads preview article, we had already hinted at this development. Now we've put it into practice.

Here's a typical WebFlux service as it looked before the migration:

// Before: WebFlux with Reactor
@RestController
@RequestMapping("/api/users")
public class UserController {

    private final WebClient webClient;
    private final ReactiveUserRepository userRepository;

    @GetMapping("/{id}/profile")
    public Mono<UserProfile> getProfile(@PathVariable String id) {
        return userRepository.findById(id)
                .flatMap(user -> webClient.get()
                        .uri("/api/avatars/{hash}", user.getAvatarHash())
                        .retrieve()
                        .bodyToMono(AvatarInfo.class)
                        .map(avatar -> new UserProfile(user, avatar))
                )
                .switchIfEmpty(Mono.error(
                        new ResponseStatusException(HttpStatus.NOT_FOUND)));
    }
}

And here's the migrated version with Spring MVC and Virtual Threads:

// After: Spring MVC with Virtual Threads
@RestController
@RequestMapping("/api/users")
public class UserController {

    private final RestClient restClient;
    private final UserRepository userRepository;

    @GetMapping("/{id}/profile")
    public UserProfile getProfile(@PathVariable String id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));

        AvatarInfo avatar = restClient.get()
                .uri("/api/avatars/{hash}", user.getAvatarHash())
                .retrieve()
                .body(AvatarInfo.class);

        return new UserProfile(user, avatar);
    }
}

The difference in readability is obvious. No Mono, no flatMap, no switchIfEmpty. Simple, sequential code that blocks -- but scales anyway thanks to Virtual Threads. Stack traces are readable again, debugging works as usual, and any JDBC-compatible database works without a reactive driver.

Over the past months, we've migrated three services from WebFlux to Spring MVC with Virtual Threads. Code complexity dropped measurably, while performance remained comparable. Only for true streaming -- Server-Sent Events and WebSocket feeds -- do we keep WebFlux, because the reactive model still offers advantages there.

Pitfalls in Practice

synchronized and Pinning

The pinning problem we described in the preview article is still relevant. When a virtual thread waits on I/O inside a synchronized block, it gets pinned to its carrier thread -- the entire advantage is lost.

The solution remains the same: replace synchronized with ReentrantLock where blocking operations occur:

// Problematic: Pinning on I/O inside synchronized
synchronized (lock) {
    connection.executeQuery(sql); // Carrier thread is blocked
}

// Correct: ReentrantLock allows unmounting
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    connection.executeQuery(sql); // Carrier thread is released
} finally {
    lock.unlock();
}

The good news: the most important libraries have caught up. HikariCP supports Virtual Threads without pinning since version 5.1, and the common JDBC drivers (PostgreSQL, MySQL Connector/J) have also reworked their synchronized blocks. Nevertheless, we recommend setting the JVM option -Djdk.tracePinnedThreads=short when enabling Virtual Threads. This makes pinning events visible in the log.

ThreadLocal and Memory Consumption

ThreadLocal variables work with Virtual Threads, but the scaling assumptions change. With 200 platform threads in a pool, 200 ThreadLocal instances are no problem. With hundreds of thousands of concurrent virtual threads, memory consumption from ThreadLocals can explode.

In our services, we systematically audited ThreadLocal usage and eliminated it where possible. For request-scoped data, we use Spring's RequestContextHolder instead, which is bound to the request rather than the thread anyway.

Sizing Connection Pools Correctly

A subtle point that gave us headaches initially: Virtual Threads potentially create thousands of simultaneous database requests. The database connection pool becomes the new bottleneck. Where previously 200 Tomcat threads needed at most 200 concurrent connections, thousands of virtual threads now try to acquire a connection simultaneously.

The solution is not to enlarge the pool -- the database has its own limits. Instead, the HikariCP pool must be deliberately kept bounded, and the connectionTimeout should be configured generously so that virtual threads wait patiently for a free connection without blocking the carrier thread.

When Virtual Threads Are the Right Choice

After several months of experience, we can clearly say:

Virtual Threads are worthwhile when the service is primarily I/O-bound -- database queries, HTTP calls to other services, message queue interactions. This applies to the vast majority of typical microservices.

Virtual Threads offer little benefit when the service performs CPU-intensive computations -- image processing, complex algorithms, cryptography. Here, the number of CPU cores is the limiting factor, not the number of threads.

Virtual Threads don't fully replace WebFlux when true streaming support is needed -- Server-Sent Events, WebSocket-based real-time data, or backpressure mechanisms.

Our Conclusion

Virtual Threads in Spring Boot are not hype -- they are a pragmatic improvement that delivers significant performance gains with minimal effort. One property in the configuration, the right library versions, and an eye on pinning and connection pools: that's all it takes.

For us at encircle360, Virtual Threads are now the standard for new Spring Boot services. The combination of Java 21 and Spring Boot 3.3 delivers a concurrency model that makes reactive overhead unnecessary for most use cases -- with full backward compatibility with existing code. This is exactly the kind of innovation that Java has been promising since Project Loom. And it delivers on that promise.

Patrick Hütter

Written by

Patrick Hütter

Founder & Software Architect

Software architect, engineer and entrepreneur. Patrick has been building products and platforms for over a decade — from enterprise backends and cloud-native infrastructure to AI-powered applications. As founder of encircle360, he combines deep technical expertise with entrepreneurial vision, driving open source projects that create real impact.