Java CompletableFuture Patterns for Async Code
Master CompletableFuture composition patterns including chaining, combining, error handling, timeouts, and async pipeline design in Java.
What you'll learn
- ✓How to chain dependent async operations with thenCompose
- ✓How to run independent operations in parallel with allOf and anyOf
- ✓Error handling and recovery strategies
- ✓Timeout patterns and executor management
Prerequisites
None — this post is self-contained.
CompletableFuture is Java’s primary tool for composing asynchronous operations. Unlike the older Future interface, it supports chaining, combining, and error recovery without blocking. This article covers practical patterns you will use when building async pipelines in production Java applications.
The Basics: thenApply vs thenCompose
The two most important composition methods are thenApply (synchronous transformation) and thenCompose (asynchronous chaining, analogous to flatMap).
import java.util.concurrent.CompletableFuture;
public class BasicComposition {
// Simulated async service calls
static CompletableFuture<String> fetchUser(int id) {
return CompletableFuture.supplyAsync(() -> {
sleep(100);
return "User-" + id;
});
}
static CompletableFuture<String> fetchProfile(String username) {
return CompletableFuture.supplyAsync(() -> {
sleep(100);
return "Profile for " + username;
});
}
public static void main(String[] args) {
// thenApply: transform the result synchronously
CompletableFuture<String> upper = fetchUser(1)
.thenApply(String::toUpperCase);
// thenCompose: chain another async operation (flatMap)
CompletableFuture<String> profile = fetchUser(1)
.thenCompose(user -> fetchProfile(user));
System.out.println(upper.join()); // USER-1
System.out.println(profile.join()); // Profile for User-1
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Use thenApply when the transformation is fast and synchronous. Use thenCompose when the next step is itself asynchronous and returns a CompletableFuture.
Parallel Independent Operations
When you need results from multiple independent services, launch them concurrently and combine the results.
allOf: Wait for Everything
import java.util.concurrent.CompletableFuture;
import java.util.List;
record Dashboard(String user, String orders, String recommendations) {}
public class ParallelFetch {
static CompletableFuture<String> fetchUserData() {
return CompletableFuture.supplyAsync(() -> {
sleep(200);
return "Alice";
});
}
static CompletableFuture<String> fetchOrders() {
return CompletableFuture.supplyAsync(() -> {
sleep(300);
return "5 orders";
});
}
static CompletableFuture<String> fetchRecommendations() {
return CompletableFuture.supplyAsync(() -> {
sleep(150);
return "3 recommendations";
});
}
static CompletableFuture<Dashboard> loadDashboard() {
var userFuture = fetchUserData();
var ordersFuture = fetchOrders();
var recosFuture = fetchRecommendations();
return CompletableFuture.allOf(userFuture, ordersFuture, recosFuture)
.thenApply(v -> new Dashboard(
userFuture.join(),
ordersFuture.join(),
recosFuture.join()
));
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
Dashboard dash = loadDashboard().join();
long elapsed = System.currentTimeMillis() - start;
System.out.printf("Dashboard: %s | %s | %s (in %d ms)%n",
dash.user(), dash.orders(), dash.recommendations(), elapsed);
// Takes ~300ms (slowest call), not 650ms (sum of all)
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
anyOf: First Response Wins
Use anyOf when you want the first result from redundant sources, for example querying multiple replicas:
static CompletableFuture<String> queryReplica(String name, long latency) {
return CompletableFuture.supplyAsync(() -> {
sleep(latency);
return "Result from " + name;
});
}
public static void main(String[] args) {
CompletableFuture<Object> fastest = CompletableFuture.anyOf(
queryReplica("US-East", 100),
queryReplica("EU-West", 200),
queryReplica("AP-South", 50)
);
System.out.println(fastest.join()); // Result from AP-South
}
Collecting Futures from a List
A common pattern is launching async work for a collection of items and waiting for all results:
static CompletableFuture<String> processItem(int item) {
return CompletableFuture.supplyAsync(() -> {
sleep(50);
return "processed-" + item;
});
}
static <T> CompletableFuture<List<T>> allOfList(List<CompletableFuture<T>> futures) {
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> futures.stream()
.map(CompletableFuture::join)
.toList());
}
public static void main(String[] args) {
List<CompletableFuture<String>> futures = List.of(1, 2, 3, 4, 5)
.stream()
.map(ParallelFetch::processItem)
.toList();
List<String> results = allOfList(futures).join();
System.out.println(results);
// [processed-1, processed-2, processed-3, processed-4, processed-5]
}
The allOfList utility method is not in the standard library but is essential for real-world async code.
Error Handling
exceptionally: Provide a Fallback
CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) throw new RuntimeException("Service down");
return "Success";
})
.exceptionally(ex -> {
System.err.println("Caught: " + ex.getMessage());
return "Fallback value";
});
handle: Transform Both Success and Failure
CompletableFuture<String> result = fetchUser(1)
.handle((value, ex) -> {
if (ex != null) {
return "Error: " + ex.getMessage();
}
return "Got: " + value;
});
whenComplete: Side Effects Without Changing the Result
CompletableFuture<String> result = fetchUser(1)
.whenComplete((value, ex) -> {
if (ex != null) {
logger.error("Failed to fetch user", ex);
} else {
logger.info("Fetched user: {}", value);
}
});
Retry Pattern
static <T> CompletableFuture<T> retry(
java.util.function.Supplier<CompletableFuture<T>> taskSupplier,
int maxRetries) {
CompletableFuture<T> future = taskSupplier.get();
for (int i = 0; i < maxRetries; i++) {
future = future.exceptionallyCompose(ex -> {
System.err.println("Retrying after: " + ex.getMessage());
return taskSupplier.get();
});
}
return future;
}
// Usage
CompletableFuture<String> result = retry(() -> fetchUnreliableService(), 3);
Timeouts
Java 9 added built-in timeout support:
import java.util.concurrent.*;
CompletableFuture<String> result = fetchUser(1)
.orTimeout(2, TimeUnit.SECONDS); // Throws TimeoutException
CompletableFuture<String> withDefault = fetchUser(1)
.completeOnTimeout("Default User", 2, TimeUnit.SECONDS); // Returns default
For more complex timeout strategies, combine with anyOf:
static <T> CompletableFuture<T> withFallbackTimeout(
CompletableFuture<T> primary,
CompletableFuture<T> fallback,
long timeout, TimeUnit unit) {
CompletableFuture<T> timedPrimary = primary
.completeOnTimeout(null, timeout, unit);
return timedPrimary.thenCompose(result -> {
if (result != null) return CompletableFuture.completedFuture(result);
return fallback;
});
}
Executor Management
By default, supplyAsync and thenApplyAsync use the common ForkJoinPool. For I/O-bound work, use a dedicated executor:
import java.util.concurrent.*;
public class ExecutorManagement {
// Dedicated executor for I/O operations
private static final ExecutorService IO_EXECUTOR =
Executors.newFixedThreadPool(20, r -> {
Thread t = new Thread(r);
t.setDaemon(true);
t.setName("io-worker-" + t.getId());
return t;
});
// With virtual threads (Java 21+)
private static final ExecutorService VIRTUAL_EXECUTOR =
Executors.newVirtualThreadPerTaskExecutor();
static CompletableFuture<String> fetchFromDb(String query) {
return CompletableFuture.supplyAsync(() -> {
// Blocking I/O runs on the IO executor
sleep(100);
return "DB result for: " + query;
}, IO_EXECUTOR);
}
static CompletableFuture<String> callExternalApi(String url) {
return CompletableFuture.supplyAsync(() -> {
sleep(200);
return "API response from: " + url;
}, VIRTUAL_EXECUTOR);
}
public static void main(String[] args) {
var result = fetchFromDb("SELECT * FROM users")
.thenCombineAsync(
callExternalApi("https://api.example.com"),
(dbResult, apiResult) -> dbResult + " + " + apiResult,
IO_EXECUTOR // Specify where the combination runs
)
.join();
System.out.println(result);
IO_EXECUTOR.shutdown();
VIRTUAL_EXECUTOR.shutdown();
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Pipeline Pattern
For complex async workflows, build pipelines as reusable method chains:
record OrderRequest(String customerId, List<String> productIds) {}
record ValidatedOrder(String customerId, List<String> productIds, double total) {}
record ProcessedOrder(String orderId, String status) {}
static CompletableFuture<ProcessedOrder> processOrder(OrderRequest request) {
return validateCustomer(request.customerId())
.thenCompose(valid -> checkInventory(request.productIds()))
.thenCompose(available -> calculateTotal(request.productIds()))
.thenApply(total -> new ValidatedOrder(
request.customerId(), request.productIds(), total))
.thenCompose(order -> chargePayment(order))
.thenCompose(charged -> createShipment(charged))
.exceptionally(ex -> new ProcessedOrder("NONE", "FAILED: " + ex.getMessage()));
}
Each step in the pipeline is a separate async operation. If any step fails, the exception propagates to exceptionally and the remaining steps are skipped.
Wrapping Up
CompletableFuture gives you a rich vocabulary for async composition. Use thenCompose for sequential async chains, allOf for parallel fan-out, and exceptionally or handle for error recovery. Always specify an executor for I/O-bound operations instead of relying on the common ForkJoinPool. With Java 21+ virtual threads, consider whether CompletableFuture pipelines are still necessary or whether straightforward blocking code on virtual threads serves you better. For many I/O-bound applications, virtual threads offer simpler code with equivalent scalability.
Related articles
- Java Java Concurrency with CompletableFuture
How CompletableFuture composes asynchronous work in Java: chaining, combining, error handling, executors, and the patterns that keep concurrent code readable.
- JavaScript JavaScript Promise Combinators: all, allSettled, race, and any
Master all four JavaScript Promise combinators -- Promise.all, allSettled, race, and any -- with practical patterns for concurrency, timeouts, and resilience.
- Java A Practical Guide to Java Virtual Threads
Master Java virtual threads with hands-on examples. Learn thread creation, executor patterns, structured concurrency, pinning pitfalls, and migration strategies.
- Java Pattern Matching in Java
Master Java pattern matching with instanceof, switch expressions, record patterns, and guarded patterns for cleaner, more expressive conditional logic.