CompletableFuture — Asynchronous Programming with supplyAsync, thenCompose, and Exception Handling
In this tutorial, you will learn about CompletableFuture. We cover key concepts, practical examples, and best practices to help you master this topic.
CompletableFuture in Java enables asynchronous, non-blocking programming with a composable future API for tasks that may complete in a different thread. Before CompletableFuture (Java 8), Java had Future — which required blocking get() calls and provided no composition. CompletableFuture lets you chain async operations declaratively.
What You'll Learn
- Creating futures: supplyAsync, runAsync, completedFuture
- Chaining: thenApply, thenCompose, thenAccept, thenRun
- Combining: thenCombine, allOf, anyOf
- Exception Handling: exceptionally, handle, whenComplete
Why It Matters
Modern applications are I/O-bound — waiting for databases, APIs, and file systems. Asynchronous programming improves throughput by not blocking threads during I/O. CompleteableFuture is the standard tool for async in Java.
Real-World Use
Microservices call multiple downstream services in parallel. Web servers handle thousands of concurrent requests without creating a thread per request. Batch processing pipelines chain stages.
Creating CompletableFuture
completedFuture
CompletableFuture<String> future = CompletableFuture.completedFuture("Hello");
String result = future.get(); // "Hello" (immediately available)
supplyAsync
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// Runs on ForkJoinPool.commonPool()
return "Task result";
});
runAsync
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
System.out.println("Running async task");
});
Custom Executor
ExecutorService executor = Executors.newFixedThreadPool(10);
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return "Result";
}, executor);
Chaining Operations
thenApply (transform)
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> "Hello")
.thenApply(s -> s + " World")
.thenApply(String::toUpperCase);
future.get(); // "HELLO WORLD"
thenCompose (flatMap)
Used when the transformation itself returns a CompletableFuture:
public CompletableFuture<String> getUserName(Long id) {
return CompletableFuture.supplyAsync(() -> "Alice");
}
CompletableFuture<String> result = CompletableFuture
.supplyAsync(() -> 42L)
.thenCompose(this::getUserName);
Without thenCompose, you would get CompletableFuture<CompletableFuture<String>>.
thenAccept (consume)
CompletableFuture
.supplyAsync(() -> "Hello")
.thenAccept(result -> System.out.println("Result: " + result));
thenRun (run after completion)
CompletableFuture
.supplyAsync(() -> "Data")
.thenRun(() -> System.out.println("Task completed"));
Combining Independent Futures
thenCombine
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "World");
CompletableFuture<String> combined = f1.thenCombine(f2, (a, b) -> a + " " + b);
combined.get(); // "Hello World"
allOf
Wait for all futures to complete:
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "A");
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "B");
CompletableFuture<String> f3 = CompletableFuture.supplyAsync(() -> "C");
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
all.get(); // blocks until all complete
// Collect results
List<String> results = Stream.of(f1, f2, f3)
.map(CompletableFuture::join)
.toList();
anyOf
CompletableFuture<Object> first = CompletableFuture.anyOf(f1, f2, f3);
Object result = first.get(); // returns the first one to complete
Exception Handling
exceptionally
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> {
if (Math.random() > 0.5) throw new RuntimeException("Failed");
return "Success";
})
.exceptionally(ex -> "Fallback: " + ex.getMessage());
future.get(); // "Success" or "Fallback: Failed"
handle
Handle both success and failure:
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> {
if (Math.random() > 0.5) throw new RuntimeException("Failed");
return "Success";
})
.handle((result, ex) -> {
if (ex != null) return "Recovered: " + ex.getMessage();
return result;
});
whenComplete
Perform side effect on completion (does not modify result):
future.whenComplete((result, ex) -> {
if (ex == null) {
System.out.println("Success: " + result);
} else {
System.err.println("Failed: " + ex.getMessage());
}
});
CompleteableFuture vs Future
| Feature | Future | CompletableFuture |
|---|---|---|
| Blocking get | Yes | Yes (but discouraged) |
| Callback/chain | No | Yes |
| Combine multiple | No | Yes (allOf, anyOf) |
| Exception handling | Manual | Built-in |
| Manual completion | No | Yes (complete, completeExceptionally) |
Common Mistakes
- Calling
get()in the main thread. This blocks the calling thread, defeating the purpose of async. PreferthenAcceptorjoinin non-blocking contexts. - Ignoring the ForkJoinPool default.
supplyAsyncuses the commonForkJoinPool. For I/O operations, provide a custom executor with more threads. - Not handling exceptions. An unhandled exception in a
CompletableFutureis silently swallowed unless you attachexceptionallyorhandle. - Blocking inside a stage. Do not call
future.get()inside athenApply— usethenComposeinstead. - Forgetting to shut down custom executors. Always call
executorService.shutdown()after use.
Practice Questions
1. What is the difference between thenApply and thenCompose?
thenApply transforms the result synchronously. thenCompose chains another CompletableFuture — used when the transformation itself returns a future (avoids nesting).
2. How do you wait for multiple CompletableFutures?
Use CompletableFuture.allOf(f1, f2, f3) which returns CompletableFuture<Void> that completes when all complete.
3. What is the difference between exceptionally and handle?
exceptionally only handles exceptions (not success). handle receives both the result and exception, allowing you to transform either.
4. Why should you use a custom executor for I/O operations?
The common ForkJoinPool has limited parallelism (usually #CPU cores). I/O operations should not consume CPU-bound threads. A custom executor with more threads prevents starvation.
5. What does completeExceptionally do?
It manually completes the future with an exception, causing dependent stages to trigger their exception handlers.
Challenge Question:
Write a method CompletableFuture<List<String>> fetchUserData(List<Long> userIds) that fetches data for each user concurrently (simulate with Thread.sleep). Limit concurrency to 3 at a time using a custom executor. Collect all results when all complete. Handle partial failures by returning a default value for failed users.
FAQ
Mini Project
Write a program CompletableFutureDemo.java that:
- Simulates fetching user data from three remote services (user profile, orders, recommendations) each taking 1-2 seconds
- Fetches all three in parallel using
supplyAsyncwith a custom executor - Combines results with
thenCombineorallOf - Transforms the combined result into a user dashboard string
- Adds exception handling: if any service fails, return a partial result
- Times the parallel execution vs sequential
- Demonstrates
thenComposeby chaining "get user -> get orders -> calculate total" - Uses
completeOnTimeout(Java 9+) to provide fallback values
What's Next
CompletableFuture and lambdas depend on functional interfaces. Lesson 38 explores the core functional interfaces in java.util.function — Predicate, Function, Consumer, Supplier — and how to create custom functional interfaces for domain-specific operations.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro