Can Java 21 Virtual Threads Replace CompletableFuture for Async Programming?

With Java 21’s virtual threads making threads cheap, the article examines whether the traditional CompletableFuture async style can be dropped, showing that simple IO can now be written synchronously, while complex task orchestration, fallback, and timeout still benefit from CompletableFuture’s richer API.

Programmer XiaoFu
Programmer XiaoFu
Programmer XiaoFu
Can Java 21 Virtual Threads Replace CompletableFuture for Async Programming?

Why CompletableFuture was widely used

Platform (carrier) threads consume about 1 MB of stack memory, so an 8 GB machine can host only a few thousand threads. To avoid exhausting the thread pool, developers turned blocking I/O into non‑blocking callback chains using CompletableFuture.

// Forced async style because threads were scarce
CompletableFuture.supplyAsync(() -> queryUser(userId))
    .thenApply(user -> queryOrders(user))
    .thenApply(orders -> calcTotal(orders))
    .thenAccept(total -> sendResponse(total));

This code is a workaround to keep the number of carrier threads low; the callback chain makes the code hard to read, debug, and maintain.

Virtual threads remove the need for that workaround

A virtual thread costs only a few kilobytes and can be scheduled millions of times on a small pool of carrier threads. When an I/O operation blocks, the virtual thread is unmounted, freeing the carrier thread.

Thread.startVirtualThread(() -> {
    User user = queryUser(userId); // blocking is fine, virtual thread will suspend
    List<Order> orders = queryOrders(user);
    BigDecimal total = calcTotal(orders);
    sendResponse(total);
});

The code is straightforward, has no callback hell, and stack traces reflect the normal call chain.

CompletableFuture provides richer task‑orchestration features

Concurrent composition

Running several tasks in parallel and merging the results is common. Example with CompletableFuture:

CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> queryUser(id));
CompletableFuture<List<Order>> orderFuture = CompletableFuture.supplyAsync(() -> queryOrders(id));
CompletableFuture<Integer> pointsFuture = CompletableFuture.supplyAsync(() -> queryPoints(id));

CompletableFuture.allOf(userFuture, orderFuture, pointsFuture).join();
UserProfile profile = merge(userFuture.join(), orderFuture.join(), pointsFuture.join());

The same can be expressed with StructuredTaskScope, but the API is still preview and less flexible:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var userTask = scope.fork(() -> queryUser(id));
    var orderTask = scope.fork(() -> queryOrders(id));
    var pointsTask = scope.fork(() -> queryPoints(id));
    scope.join();
    scope.throwIfFailed();
    UserProfile profile = merge(userTask.get(), orderTask.get(), pointsTask.get());
}
StructuredTaskScope

enforces structured concurrency—every sub‑task must finish before the scope closes—limiting flexibility compared with the free‑form composition of CompletableFuture.

Race mode

Selecting the fastest result is trivial with CompletableFuture.anyOf:

CompletableFuture.anyOf(searchGoogle(), searchBing(), searchBaidu())
    .thenAccept(result -> useResult(result));

With virtual threads the equivalent requires StructuredTaskScope.ShutdownOnSuccess, which adds more boilerplate and is less expressive.

Exception handling and fallback

CompletableFuture

allows declarative error handling:

CompletableFuture.supplyAsync(() -> queryFromMainDB())
    .exceptionally(ex -> queryFromBackupDB())
    .thenApply(data -> transform(data))
    .handle((result, ex) -> {
        if (ex != null) return defaultValue();
        return result;
    });

The same logic in a virtual‑thread block needs nested try‑catch statements:

Thread.startVirtualThread(() -> {
    Object data;
    try {
        data = queryFromMainDB();
    } catch (Exception e1) {
        try {
            data = queryFromBackupDB();
        } catch (Exception e2) {
            data = defaultValue();
        }
    }
    Object result = transform(data);
    sendResponse(result);
});

Declarative timeout control

CompletableFuture

provides one‑line timeout handling:

CompletableFuture.supplyAsync(() -> slowQuery())
    .orTimeout(3, TimeUnit.SECONDS)
    .completeOnTimeout(defaultValue(), 3, TimeUnit.SECONDS);

With virtual threads you must use Future.get(timeout) or configure timeout parameters on StructuredTaskScope, which is more verbose.

Can StructuredTaskScope replace CompletableFuture?

No, at least not in the short term. StructuredTaskScope is designed for structured concurrency; every sub‑task must complete within the scope and cannot be composed across async boundaries (e.g., thenCompose). CompletableFuture is a generic Promise/Future that can be completed anywhere, combined arbitrarily, and thus remains more flexible. StructuredTaskScope is like a fenced playground—safe but with limited range. CompletableFuture is like an open road—flexible but requiring careful safety management.

Practical guidance

For simple asynchronous I/O (database queries, HTTP calls, file reads) use virtual threads with synchronous code: the code is simple, stack traces are clear, and debugging is easy.

For complex multi‑task orchestration, race conditions, fallback strategies, or timeout handling, keep using CompletableFuture because its API is designed for those scenarios and reduces the chance of errors.

Mixing both approaches is acceptable. CompletableFuture can run on a virtual‑thread executor, gaining the lightweight thread advantage while retaining rich composition features:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> queryUser(id), executor);
CompletableFuture<List<Order>> orderFuture = CompletableFuture.supplyAsync(() -> queryOrders(id), executor);
CompletableFuture.allOf(userFuture, orderFuture).join();

This hybrid style leverages virtual threads' low cost and CompletableFuture's orchestration capabilities.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaConcurrencyCompletableFutureVirtual ThreadsAsync ProgrammingJava 21StructuredTaskScope
Programmer XiaoFu
Written by

Programmer XiaoFu

xiaofucode.com – a programmer learning guide driven by the pursuit of profit

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.