Improving I/O Concurrency with Spring @Async and Virtual Threads
Often, you need to take action after persisting or updating data in the DB. A typical example would be sending an email after creating a user.
We often like to use business events to decouple these behaviours.
A straightforward approach in Spring would be:
public record UserCreatedEvent(
UUID userId,
String email
) {
}
@Service
@RequiredArgsConstructor
public class CreateUserUseCase {
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public User createUser(String email) {
User user = userRepository.save(new User(email));
eventPublisher.publishEvent(
new UserCreatedEvent(user.getId(), user.getEmail())
);
return user;
}
}
@Component
@RequiredArgsConstructor
public class UserCreatedEventListener {
private final EmailClient emailClient;
@TransactionalEventListener
public void onUserCreated(UserCreatedEvent event) {
emailClient.sendWelcomeEmail(event.email());
}
}
@TransactionalEventListener allows us to execute the event handler after the successful commit of the transaction by default.
With Spring’s default event multicaster, listeners run synchronously on the thread that publishes the event. Here, that is the HTTP request thread.
A direct impact of this is that the email-sending latency is now added to your endpoint latency. If the email API responds after one second, your client will wait one more second before getting its response.
Adding @Async
It can be disappointing that sending our email adds time to the HTTP call because everything is executed synchronously. We would like to tell the user that their account creation is completed as soon as the database transaction has committed successfully.
Spring provides @Async, an AOP-based annotation that allows a method invocation to be delegated to a different TaskExecutor.
@EnableAsync activates this annotation support, and an executor can be explicitly selected through the value of @Async.
We can configure a dedicated executor and run the listener asynchronously:
@Configuration
@EnableAsync
public class AsyncConfiguration {
@Bean("emailExecutor")
public ThreadPoolTaskExecutor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(50);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(1_000);
return executor;
}
}
@Component
@RequiredArgsConstructor
public class UserCreatedEventListener {
private final EmailClient emailClient;
@Async("emailExecutor")
@TransactionalEventListener
public void onUserCreated(UserCreatedEvent event) {
emailClient.sendWelcomeEmail(event.email());
}
}
Here, the event is executed on our new thread pool.
Maximum 50 concurrent tasks Email-->>Pool: Email sent
This is a reasonable solution, but its concurrency is bounded by the number of platform threads in the pool. For workloads that spend most of their time blocked on I/O, the pool can become a throughput bottleneck under contention.
The fixed size of the pool also limits how many email requests can be in progress at the same time. Once every thread is busy, the remaining tasks have to wait in the executor queue.
Virtual Threads to the Rescue
With the arrival of virtual threads in Java 21, the Java architects wanted to offer a solution for executing a very large number of concurrent blocking tasks, such as HTTP calls or other waiting operations, while keeping the same imperative programming paradigm.
When a virtual thread encounters a supported blocking operation, such as waiting for data from a network connection, the JVM can suspend the virtual thread and use the underlying OS thread to run another virtual thread until the response arrives.
HttpResponse<String> response = httpClient.send(
request,
HttpResponse.BodyHandlers.ofString()
); // Blocks the calling virtual thread until the response is received
To sum up, virtual threads do not make your code faster. They unlock concurrency for I/O tasks at a limited cost, without coloring your code, especially when compared with JavaScript async code, reactive programming or Kotlin coroutines.
Virtual threads are designed for tasks that spend most of their time waiting. They are not intended to improve long-running CPU-intensive operations.
Our benchmark models email delivery as an I/O-bound operation that spends almost all its time waiting.
We can therefore modify our configuration to create one virtual thread per asynchronous task:
@Configuration
@EnableAsync
public class AsyncConfiguration {
@Bean("emailExecutor")
public VirtualThreadTaskExecutor emailExecutor() {
return new VirtualThreadTaskExecutor();
}
}
Carrier thread is free for other work Email-->>VT: Email sent
VirtualThreadTaskExecutor is available since Spring Framework 6.1 and requires JDK 21 or later. It creates a new virtual thread for every submitted task instead of maintaining a traditional thread pool.
Benchmark
I created a focused benchmark to compare the three execution models under an intentionally I/O-bound workload.
After a warm-up, we send 1,000 requests. Each request creates a user in the DB and simulates an email sending operation that takes 100 ms.
We maintain 100 concurrent HTTP requests to show relevant results with the three different approaches:
| Approach | Request P95 (ms) | Requests/s |
|---|---|---|
| Sync | 139.54 | 704.85 |
| Async platform | 39.39 | 1,777.61 |
| Async virtual | 43.27 | 1,767.80 |
Both asynchronous alternatives release the HTTP thread earlier, greatly reducing the P95 response time. Their HTTP results are similar because, from the client point of view, both approaches move the email sending outside the HTTP response path.
The main difference appears in how fast the application can process the submitted background tasks.
Throughput under increasing concurrency
To make the platform thread pool saturation more visible, I also compared both asynchronous approaches at increasing concurrency levels:
| Concurrency | Async platform emails/s | Async virtual emails/s |
|---|---|---|
| 25 | 225.05 | 223.79 |
| 50 | 446.51 | 447.06 |
| 100 | 479.89 | 895.97 |
At 25 and 50 concurrent tasks, both approaches have almost identical throughput. At 100, the platform executor is already limited by its 50-thread pool and plateaus around 480 emails/s. The virtual-thread executor continues scaling to around 896 emails/s because blocked tasks do not each retain a platform thread.
Limitations and Production Considerations
There are a few things to keep in mind:
- The email API is only simulated with
Thread.sleep()and is not a real API call. A real HTTP call would introduce network processing, serialization and more CPU usage, resulting in slightly lower throughput. - We do not track memory usage. Virtual threads are generally much cheaper for maintaining large numbers of blocked tasks, but this benchmark does not prove that they use less memory in this specific application.
- A real-world architecture may prefer a more resilient event-publishing pattern, such as the transactional outbox pattern or CDC.
- With a virtual-thread-per-task executor, we may need some form of backpressure or concurrency limit, such as a semaphore, to avoid exceeding the email API rate limit.
Conclusion
Virtual threads are a great new tool in the Java toolbox. Their main appeal is that they let us handle much more blocking I/O without having to rewrite our code around a completely different programming model.
They pair especially well with Spring’s @Async for background tasks that spend most of their time waiting on external services. And with Java 25 now available as an LTS, they also benefit from the improvements made since their introduction in Java 21, notably around thread pinning.
They will not replace every thread pool, but for I/O-intensive workloads, they are now an option that is both simple to adopt and worth measuring.