When to use CompletableFuture vs Java 21 virtual threads — comparing programming models, error handling, debugging, and performance for real concurrent IO workloads.
Java 21 made virtual threads production-ready, and ever since I’ve had the same conversation with teams: do we migrate our CompletableFuture chains to virtual threads? The answer isn’t as simple as “virtual threads are newer, therefore better”. I’ve used both extensively — CompletableFuture in trading systems at Mosaic Smart Data where we needed complex async pipelines over financial data feeds, and virtual threads at DWP Digital where we had hundreds of concurrent HTTP calls to legacy upstream services. They solve different problems well. Understanding the trade-offs is the difference between code that’s fast and readable and code that’s fast and a debugging nightmare.
Let’s make it concrete. You need to call three external services, combine the results, and return a response. Here it is with CompletableFuture:
public UserProfile buildProfile(String userId) throws ExecutionException, InterruptedException {
CompletableFuture<PersonalDetails> personalFuture =
CompletableFuture.supplyAsync(() -> personalService.fetch(userId), executor);
CompletableFuture<EntitlementDetails> entitlementFuture =
CompletableFuture.supplyAsync(() -> entitlementService.fetch(userId), executor);
CompletableFuture<ClaimHistory> historyFuture =
CompletableFuture.supplyAsync(() -> claimHistoryService.fetch(userId), executor);
return CompletableFuture
.allOf(personalFuture, entitlementFuture, historyFuture)
.thenApply(v -> UserProfile.builder()
.personal(personalFuture.join())
.entitlements(entitlementFuture.join())
.history(historyFuture.join())
.build())
.get();
}
And here’s the same logic with virtual threads using structured concurrency (Java 21):
public UserProfile buildProfile(String userId) throws InterruptedException, ExecutionException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
StructuredTaskScope.Subtask<PersonalDetails> personal =
scope.fork(() -> personalService.fetch(userId));
StructuredTaskScope.Subtask<EntitlementDetails> entitlements =
scope.fork(() -> entitlementService.fetch(userId));
StructuredTaskScope.Subtask<ClaimHistory> history =
scope.fork(() -> claimHistoryService.fetch(userId));
scope.join().throwIfFailed();
return UserProfile.builder()
.personal(personal.get())
.entitlements(entitlements.get())
.history(history.get())
.build();
}
}
The virtual threads version reads sequentially. There are no callbacks, no thenApply chains. The forked subtasks run concurrently on virtual threads, and scope.join() waits for all of them. ShutdownOnFailure cancels the remaining tasks if any one fails — behaviour you’d have to wire manually with CompletableFuture.
CompletableFuture is a monadic pipeline API. It composes well when the logic is linear: fetch → transform → combine → return. Where it degrades is when error handling becomes conditional, when you need early exits, or when you need to branch based on an intermediate result:
// This is where CompletableFuture gets ugly
CompletableFuture<Result> complex = fetchData()
.thenCompose(data -> {
if (data.isExpired()) {
return refreshAndFetch(); // different code path
}
return CompletableFuture.completedFuture(data);
})
.thenApply(data -> transform(data))
.exceptionally(ex -> {
if (ex.getCause() instanceof TimeoutException) {
return cachedResult(); // fallback in exception handler
}
throw new CompletionException(ex);
});
With virtual threads, the same logic is just… Java:
Result complex() {
Data data = fetchData();
if (data.isExpired()) {
data = refreshAndFetch();
}
try {
return transform(data);
} catch (TimeoutException e) {
return cachedResult();
}
}
Branches, try-catch, early returns — all the control flow you already know. The concurrency is handled at the call site by the executor, not baked into the code itself.
CompletableFuture exception handling has a well-known hazard: exceptions are wrapped in CompletionException and propagated through the chain. Unwrapping them correctly requires knowing which exceptions come from which stage:
future.exceptionally(ex -> {
Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex;
if (cause instanceof NotFoundException) {
return defaultValue();
}
throw new CompletionException(cause); // re-wrap and propagate
});
Miss the .getCause() unwrap and you’ll find your code catching CompletionException when you expected a domain exception, or letting domain exceptions escape uncaught because the instanceof check tested the wrong type.
With virtual threads, exceptions propagate as you’d expect from synchronous code. A checked exception thrown inside scope.fork() surfaces as the cause of an ExecutionException from scope.join() — one unwrap, predictable behaviour.
This is where virtual threads have a decisive advantage in practice. A CompletableFuture chain runs across multiple thread pool stages. When something goes wrong, the stack trace shows the internal ForkJoinPool machinery and a callback invocation — not the business code that triggered the failure.
A virtual thread stack trace looks like synchronous code. The blocking call that failed, the method that called it, the method above that — a readable, linear stack. I spent less time debugging concurrency issues at DWP Digital after we moved high-concurrency IO paths to virtual threads than I had spent in the previous year fighting CompletableFuture exception chains.
Both models are fast enough for typical service workloads. The relevant differences:
CompletableFuture dispatches tasks to a fixed thread pool (typically ForkJoinPool.commonPool() unless you supply a custom executor). Under high concurrency it’s bounded by the pool size. With a pool of 200 threads, you handle 200 concurrent IO operations — more and you queue.
Virtual threads are not pooled — each one is cheap to create (a few hundred bytes of stack) and blocked virtual threads don’t hold a platform thread. If you spawn 10,000 concurrent HTTP calls on virtual threads, the JVM manages the scheduling against a platform thread carrier pool without your intervention.
The performance difference matters most in high-fan-out scenarios: making hundreds of concurrent external calls per request, batch processing with many parallel IO operations, or server-side code handling thousands of simultaneous connections. For typical microservice work — three to ten concurrent calls per request — the performance difference is negligible.
CompletableFuture retains an advantage in CPU-bound pipeline work where you want fine-grained control over thread pool sizing and task priority. The ForkJoinPool with work-stealing is well-suited to compute tasks. Virtual threads block cheaply but still run on platform threads — they don’t make CPU-bound code faster.
If you have existing CompletableFuture code that works, there’s no urgency to rewrite it. Migrate incrementally:
// application.properties
spring.threads.virtual.enabled=true
This makes each HTTP request handler run on a virtual thread. Existing blocking IO in request handlers stops blocking platform threads — immediate throughput improvement for IO-heavy workloads, zero code changes.
For new concurrent code, prefer virtual threads and structured concurrency over new CompletableFuture chains.
Migrate existing CompletableFuture code when you’re in the file anyway for another change — not as a dedicated rewrite pass. A CompletableFuture.allOf fan-out over a handful of tasks is a natural candidate; a complex multi-stage pipeline with error recovery is worth doing carefully.
Executors.newVirtualThreadPerTaskExecutor() creates a new virtual thread per submitted task. This is correct — virtual threads are not pooled. Wrapping them in a bounded pool defeats the purpose.synchronized block pins itself to its carrier platform thread. Under high concurrency this can exhaust platform threads. Replace synchronized with ReentrantLock in hot paths that you’re moving to virtual threads.CompletableFuture for reactive-style pipeline composition. If you have a well-understood async pipeline with thenApply → thenCombine → thenCompose that’s already working and readable, leave it. The migration cost to virtual threads rarely pays back for a pipeline that isn’t causing problems.StructuredTaskScope requires --enable-preview. For production use without preview flags, use Executors.newVirtualThreadPerTaskExecutor() with a Future and explicit .get() instead.If you’re modernising a Java backend and want a contractor who knows the concurrency trade-offs in production, get in touch.