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.

sequenceDiagram participant Client participant HTTP as HTTP request thread participant DB participant Email as Email API Client->>HTTP: POST /users HTTP->>DB: Save user DB-->>HTTP: User saved HTTP->>Email: Send welcome email Email-->>HTTP: Email sent HTTP-->>Client: HTTP 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.

sequenceDiagram participant Client participant HTTP as HTTP request thread participant DB participant Pool as Async platform thread pool participant Email as Email API Client->>HTTP: POST /users HTTP->>DB: Save user DB-->>HTTP: User saved HTTP->>Pool: Submit listener task HTTP-->>Client: HTTP response Pool->>Email: Send welcome email Note over Pool: One pool thread blocked
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();
    }
}
sequenceDiagram participant Client participant HTTP as HTTP request thread participant DB participant Executor as VirtualThreadTaskExecutor participant VT as Virtual thread participant Email as Email API Client->>HTTP: POST /users HTTP->>DB: Save user DB-->>HTTP: User saved HTTP->>Executor: Submit listener task Executor->>VT: Start virtual thread HTTP-->>Client: HTTP response VT->>Email: Send welcome email Note over VT: Blocked and unmounted while waiting
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:

ApproachRequest P95 (ms)Requests/s
Sync139.54704.85
Async platform39.391,777.61
Async virtual43.271,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:

ConcurrencyAsync platform emails/sAsync virtual emails/s
25225.05223.79
50446.51447.06
100479.89895.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.