Actually, I should clarify — I used to hate waiting. Whether it’s standing in line for coffee or watching a spinner on a dashboard, waiting felt like a waste of life. In code, it’s worse. It’s a waste of resources.
But the landscape (oops, I mean ecosystem) has changed. We aren’t just stuck with CompletableFuture anymore. And understanding how we got here is critical if you want your apps to actually perform.
The “Callback Hell” Era (That We Still Use)
Before Virtual Threads took over the world, CompletableFuture was our best weapon against blocking. Probably, it’s still everywhere in legacy codebases, and honestly, for certain stream-processing tasks, it’s still pretty fast.
The idea was simple: don’t wait for the result. Pass a function that says “do this when you’re done.” But chaining these together? It gets messy fast.
Here is a snippet from a payment gateway integration I refactored last week. We needed to fetch user details, validate a token, and then process a transaction—all without blocking the main thread.
public CompletableFuture<TransactionResult> processPaymentAsync(String userId, double amount) {
return fetchUser(userId)
.thenCompose(user -> validateToken(user.getToken())
.thenCompose(valid -> {
if (!valid) {
return CompletableFuture.failedFuture(new SecurityException("Invalid Token"));
}
return bankClient.charge(user.getAccountId(), amount);
}))
.exceptionally(ex -> {
logger.error("Payment failed for user " + userId, ex);
return TransactionResult.FAILED;
});
}
// Mock methods for context
private CompletableFuture<User> fetchUser(String id) {
return CompletableFuture.supplyAsync(() -> db.findUser(id));
}
Enter Virtual Threads: The “Sync” Async
When Java 21 dropped, everyone lost their minds over Virtual Threads. And it’s just… how we write code.
I rewrote that same payment logic recently using the modern approach. Look at the difference:
public TransactionResult processPaymentVirtual(String userId, double amount) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<User> userFuture = executor.submit(() -> db.findUser(userId));
User user = userFuture.get();
boolean isValid = validateTokenSync(user.getToken());
if (!isValid) {
throw new SecurityException("Invalid Token");
}
return bankClient.chargeSync(user.getAccountId(), amount);
} catch (ExecutionException | InterruptedException e) {
logger.error("Payment failed", e);
return TransactionResult.FAILED;
}
}
Structured Concurrency: The Real Power Move
But simply swapping threads isn’t enough. The real danger in async programming is “orphaned tasks”—threads that keep running even after the request that spawned them has failed or timed out. I’ve seen this leak memory in long-running services more times than I care to admit.
This is where Structured Concurrency comes in. It treats a group of related async tasks as a single unit of work. If one fails, they all shut down. It’s like a transaction for threads.
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
public Response aggregateUserData(String userId) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Orders> orders = scope.fork(() -> orderService.getOrders(userId));
Subtask<Preferences> prefs = scope.fork(() -> prefService.get(userId));
Subtask<History> history = scope.fork(() -> historyService.get(userId));
scope.join();
scope.throwIfFailed();
return new Response(orders.get(), prefs.get(), history.get());
} catch (ExecutionException | InterruptedException e) {
throw new RuntimeException("Data aggregation failed", e);
}
}
Streams: Parallel vs. Async
A common mistake I see juniors make is assuming parallelStream() is the solution to async problems. It’s not. Parallel streams use the common ForkJoinPool. If you do blocking I/O inside a parallel stream, you are destroying the performance of your entire application because that pool is shared across the JVM.
Here is a benchmark I ran on my M2 MacBook (yeah, I still have an old ARM machine for testing) processing 500 image resizing tasks:
- Parallel Stream: 12.4 seconds (and the CPU fan went crazy)
- Virtual Threads (Executor): 3.1 seconds
When to Stick with CompletableFuture?
Is CompletableFuture dead? Not entirely. I still use it when I’m working with reactive libraries that haven’t fully embraced the blocking-is-fine paradigm yet, or when I need complex pipeline combinations like anyOf that are just slightly more verbose in the structured concurrency world.
But for 95% of business logic—fetching data, saving to a DB, calling a REST API—stop complicating your life. The mental overhead of async callbacks isn’t worth it anymore. Treat your threads like they are cheap, because now, they actually are.
CompletableFuture documentation JEP 425: Virtual Threads