CompletableFuture vs Virtual Threads: When to Use Each
Java 21 introduced virtual threads as a production-ready feature, fundamentally changing how we approach asynchronous programming. For years, CompletableFuture has been the go-to tool for non-blocking operations, but virtual threads offer a compelling alternative with a dramatically different programming model. Understanding when to use each approach is crucial for building efficient, maintainable Java applications.
Two Different Philosophies
CompletableFuture uses a callback-based style while virtual threads allow a synchronous coding style for asynchronous logic. This represents a fundamental philosophical difference:
CompletableFuture: Chain operations through callbacks (thenApply, thenCompose, thenAccept), explicitly managing asynchronous flow.
Virtual Threads: Write straightforward, imperative code that looks synchronous but executes asynchronously under the hood.
Consider a simple example of fetching user data:
// CompletableFuture approach
CompletableFuture<User> userFuture = CompletableFuture
.supplyAsync(() -> fetchUser(userId))
.thenApply(user -> enrichUserData(user))
.thenApply(user -> validateUser(user));
// Virtual threads approach
User user = fetchUser(userId);
user = enrichUserData(user);
user = validateUser(user);
With CompletableFuture, you chain operations using methods like thenApply, thenCompose and thenAccept. Virtual threads let you write simple imperative code that reads more naturally.
Understanding Virtual Threads
Virtual threads are lightweight threads that reduce the effort of writing, maintaining, and debugging high-throughput concurrent applications. Unlike platform threads that map directly to operating system threads, virtual threads are managed entirely by the JVM.
The key efficiency comes from what happens during blocking operations. When a virtual thread performs a blocking I/O operation, it can unmount from its carrier (platform thread). The carrier gets free, so the JVM can mount a different virtual thread on that carrier. This allows you to create millions of virtual threads with minimal overhead.
// Creating virtual threads is straightforward
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
String result = blockingHttpCall(); // Automatically unmounts
processResult(result);
});
The Thread Pinning Problem
Virtual threads have a significant limitation: thread pinning. A current limitation is that performing a blocking operation while inside a synchronized block or method causes the JDK’s virtual thread scheduler to block a precious OS thread, whereas it wouldn’t if the blocking operation were done outside of a synchronized block or method.
This affects real-world applications significantly. JDBC operations typically cause thread pinning. This is partly because many JDBC drivers use synchronised blocks internally, but also because native blocking calls themselves can pin the thread.
However, progress is being made. The PostgreSQL JDBC driver version 42.6.0 replaced virtually all synchronized methods with reentrant locks, addressing pinning issues. The MySQL connector is following suit.
Important: Java 24 (released March 2025) introduced JEP 491, which solves synchronized-related pinning scenarios. If you’re on Java 24+, many pinning concerns are eliminated.
When CompletableFuture Still Makes Sense
Despite virtual threads’ advantages, CompletableFuture remains valuable in several scenarios:
1. Complex Composition Patterns
CompletableFuture excels at declaratively composing multiple asynchronous operations:
CompletableFuture<Report> report = CompletableFuture
.allOf(fetchSales(), fetchInventory(), fetchAnalytics())
.thenApply(v -> combineResults(
fetchSales().join(),
fetchInventory().join(),
fetchAnalytics().join()
));
2. Existing Reactive Codebases
Existing codebases and simpler asynchronous tasks might be well-served by CompletableFuture’s established API. Migration to virtual threads requires testing and consideration of framework compatibility.
3. Explicit Executor Control
When you need fine-grained control over thread pool configuration and resource allocation, CompletableFuture with custom executors provides more flexibility.
4. APIs Returning CompletableFuture
Many Java libraries and frameworks expose CompletableFuture-based APIs. While you can bridge these to virtual threads, it may be simpler to work with them directly.
When Virtual Threads Shine
1. I/O-Bound Operations
Virtual threads are suitable for running tasks that spend most of the time blocked, often waiting for I/O operations to complete. This makes them ideal for:
- HTTP clients making external API calls
- Database queries (with modern, pinning-aware drivers)
- File I/O operations
- Message queue consumers
2. Simplified Code Structure
Virtual threads eliminate callback hell. Compare these approaches for a multi-step process:
// CompletableFuture: callback chains
CompletableFuture<Result> future = fetchUser(userId)
.thenCompose(user -> fetchOrders(user))
.thenCompose(orders -> calculateTotal(orders))
.thenApply(total -> generateReport(total));
// Virtual threads: straightforward sequence
User user = fetchUser(userId);
List<Order> orders = fetchOrders(user);
BigDecimal total = calculateTotal(orders);
Report report = generateReport(total);
Virtual threads have lower resource overhead than threads/thread pools used by completable futures, making them more efficient for high-concurrency scenarios.
3. Easier Error Handling
Exception handling is more natural with virtual threads:
// Virtual threads: standard try-catch
try {
Result result = riskyOperation();
process(result);
} catch (IOException e) {
handleError(e);
}
// CompletableFuture: callback-based error handling
CompletableFuture<Result> future = CompletableFuture
.supplyAsync(() -> riskyOperation())
.exceptionally(ex -> {
handleError(ex);
return defaultValue();
});
Combining Both Approaches
You can use CompletableFuture with virtual threads to get the best of both worlds. CompletableFuture is able to use virtual threads and handle asynchronous processing in a very efficient manner:
Executor virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> blockingOperation(), virtualExecutor);
Data fetchers explicitly returning CompletableFuture do not get wrapped in a virtual thread, allowing you to keep the option of managing your own thread pools if needed.
Structured Concurrency: The Third Option
Java 21 also introduced Structured Concurrency (preview feature) as a higher-level abstraction for managing concurrent tasks. The principal class is StructuredTaskScope in the java.util.concurrent package, which allows developers to structure a task as a family of concurrent subtasks and coordinate them as a unit.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<String> user = scope.fork(() -> findUser());
Supplier<Integer> order = scope.fork(() -> fetchOrder());
scope.join(); // Wait for all subtasks
scope.throwIfFailed(); // Propagate errors
return new Response(user.get(), order.get());
}
StructuredTaskScope provides two policies: ShutdownOnFailure captures the first exception and shuts down the task scope, while ShutdownOnSuccess captures the first result and shuts down the task scope to interrupt unfinished threads.
This approach offers automatic resource cleanup, clear task hierarchies, and built-in error propagation—benefits that neither CompletableFuture nor raw virtual threads provide alone.
Practical Decision Framework
Here’s a decision tree for choosing the right approach:
| Approach | Choose When |
|---|---|
| Virtual Threads | • Writing new I/O-bound code from scratch • Working with blocking APIs (JDBC, file I/O, HTTP clients) • Team prefers imperative, sequential code style • Using Java 21+ with pinning-aware libraries • Building high-throughput services with many concurrent requests |
| CompletableFuture | • Working with existing reactive codebases • Need explicit executor control and thread pool tuning • Composing multiple independent async operations • Integrating with libraries that expose CompletableFuture APIs • Team is comfortable with reactive programming patterns |
| Structured Concurrency | • Managing groups of related concurrent tasks • Need robust error propagation and cancellation • Want automatic resource cleanup • Working with virtual threads and need better coordination |
Performance Considerations
Benchmarks confirm that R2DBC presents a huge improvement compared to traditional threads, but in terms of performance, virtual threads dethrone R2DBC. This means you can often achieve reactive-level performance with simpler, blocking-style code.
However, be aware of limitations:
- Virtual threads aren’t intended for long-running CPU-intensive operations
- Monitor for pinning using
-Djdk.tracePinnedThreads=full - Framework limitations: Switching from normal threads to virtual threads can have unforeseen consequences for legacy applications, requiring thorough testing
The Future is Hybrid
The future of asynchronous programming in Java likely involves a coexistence of both approaches. Virtual threads don’t obsolete CompletableFuture; they complement it. The choice depends on your specific requirements, team preferences, and existing architecture.
For new projects starting with Java 21+, virtual threads offer a compelling default for I/O-bound operations. They simplify code, reduce bugs, and eliminate the need for complex reactive chains. But CompletableFuture remains a powerful tool when you need explicit composition or are working within existing reactive architectures.
Conclusion
Virtual threads represent a paradigm shift in Java concurrency, bringing simplicity back to asynchronous programming without sacrificing performance. They allow developers to write code that looks synchronous but scales like asynchronous code. However, CompletableFuture isn’t going away—it remains valuable for complex composition patterns and existing reactive codebases.
The key is understanding the strengths of each approach and choosing the right tool for your specific use case. With Java 21’s toolset including virtual threads, CompletableFuture, and structured concurrency, you have more options than ever for building efficient, maintainable concurrent applications.
Additional Resources
- JEP 444: Virtual Threads – Official specification
- JEP 453: Structured Concurrency – Structured concurrency preview
- JEP 491: Synchronize Virtual Threads without Pinning – Java 24 pinning fix
- Oracle: Virtual Threads Documentation – Comprehensive guide
- PostgreSQL JDBC 42.6.0 Release – Virtual thread compatibility


