CompletableFuture: Asynchronous Execution and Combining Results
1. Overview
An API often has to call several services: fetch customer details, orders, and shipping fees. If you call them one after another, the total time equals the sum of every call’s latency. CompletableFuture lets you run independent calls in parallel, then combine the results once they are all done.
After reading this article, you will know how to create asynchronous tasks, chain processing steps (thenApply, thenCompose), and combine results (thenCombine, allOf, anyOf). You will also learn how to handle errors, set timeouts, and write tests for asynchronous code.
2. Setting Up the Example
We use three simulated services. Each one “sleeps” for a while to mimic network latency:
import java.math.BigDecimal;
import java.util.List;
record Customer(long id, String name, String city) {}
record Order(long id, long customerId, BigDecimal total) {}
final class Delay {
static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
}
class CustomerService {
Customer findById(long id) {
Delay.sleep(300);
return new Customer(id, "Lan", "Hue");
}
}
class OrderService {
List<Order> findByCustomer(long customerId) {
Delay.sleep(400);
return List.of(
new Order(1, customerId, new BigDecimal("150000")),
new Order(2, customerId, new BigDecimal("90000")));
}
}
class ShippingService {
BigDecimal feeFor(String city) {
Delay.sleep(200);
return "Hue".equals(city) ? new BigDecimal("30000") : new BigDecimal("45000");
}
}
These three services use only the JDK. You don’t need any extra libraries to run them.
3. Creating Asynchronous Tasks
3.1. supplyAsync and runAsync
supplyAsync runs a Supplier and returns a CompletableFuture<T>. runAsync runs a Runnable that has no result and returns a CompletableFuture<Void>:
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CreateDemo {
public static void main(String[] args) {
var customerService = new CustomerService();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
CompletableFuture<Customer> customerFuture =
CompletableFuture.supplyAsync(() -> customerService.findById(42L), executor);
CompletableFuture<Void> auditFuture =
CompletableFuture.runAsync(() -> System.out.println("Writing access log"), executor);
System.out.println("Main thread is not blocked");
System.out.println(customerFuture.join());
auditFuture.join();
}
}
}
Main thread is not blocked
Writing access log
Customer[id=42, name=Lan, city=Hue]
The order of the first two lines may change, because they run on two different threads. Keep these points in mind:
- The second parameter is an
Executor. If you omit it, the task runs onForkJoinPool.commonPool(). Executors.newVirtualThreadPerTaskExecutor()(Java 21) creates one virtual thread per task. This suits I/O-bound work such as HTTP calls or database queries.- Since Java 19,
ExecutorServiceisAutoCloseable. Thetry-with-resourcesblock waits for the tasks to finish before closing the executor. join()blocks the current thread until a result is available. If the task fails,join()throwsCompletionException(unchecked).get(), by contrast, throwsExecutionException(checked).
3.2. Completing a Future Manually
Sometimes the result comes from a callback rather than from a lambda. In that case, create an empty future and complete it yourself:
CompletableFuture<String> paymentResult = new CompletableFuture<>();
// Somewhere else, for example in the payment gateway's callback:
paymentResult.complete("PAID");
System.out.println(paymentResult.join()); // PAID
To report a failure, call completeExceptionally(new RuntimeException("Card declined")). When you already have a value, CompletableFuture.completedFuture(value) is the shortest way to create a completed future.
4. Chaining Processing Steps
4.1. thenApply and thenAccept
thenApply transforms the result, similar to map on a Stream. thenAccept consumes the result and returns nothing:
CompletableFuture<String> greeting = CompletableFuture
.supplyAsync(() -> customerService.findById(42L), executor)
.thenApply(Customer::name)
.thenApply(name -> "Hello, " + name);
greeting.thenAccept(System.out::println).join();
Hello, Lan
Each thenApply call returns a new CompletableFuture. The original future is not modified.
4.2. thenCompose When the Next Step Is Also Asynchronous
To fetch orders, we need the customerId from the previous step. If you use thenApply with a function that returns a future, the result is CompletableFuture<CompletableFuture<List<Order>>>. thenCompose flattens this nested type, similar to flatMap:
CompletableFuture<List<Order>> ordersFuture = CompletableFuture
.supplyAsync(() -> customerService.findById(42L), executor)
.thenCompose(customer -> CompletableFuture.supplyAsync(
() -> orderService.findByCustomer(customer.id()), executor));
System.out.println(ordersFuture.join().size()); // 2
These two steps depend on each other, so they still run sequentially. The total time is about 700 ms. thenCompose does not make things faster. It lets you chain steps without blocking any thread.
5. Combining Results
CompletableFuture really shines with independent tasks.
5.1. thenCombine for Two Futures
Suppose we already know the customer’s city and want to fetch the order list and the shipping fee at the same time:
long start = System.currentTimeMillis();
CompletableFuture<List<Order>> orders =
CompletableFuture.supplyAsync(() -> orderService.findByCustomer(42L), executor);
CompletableFuture<BigDecimal> shippingFee =
CompletableFuture.supplyAsync(() -> shippingService.feeFor("Hue"), executor);
CompletableFuture<BigDecimal> grandTotal = orders.thenCombine(shippingFee,
(orderList, fee) -> orderList.stream()
.map(Order::total)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.add(fee));
System.out.println(grandTotal.join());
System.out.println("Took " + (System.currentTimeMillis() - start) + " ms");
270000
Took 405 ms
The two calls run in parallel. The total time is roughly that of the slower call (400 ms), instead of 600 ms if they ran sequentially. The function passed as the second argument to thenCombine runs only after both futures have completed.
5.2. allOf for Many Futures
When you have more than two tasks, use allOf. It returns a CompletableFuture<Void>, so you have to collect the results from each child future yourself:
List<String> cities = List.of("Hue", "Hanoi", "Danang");
List<CompletableFuture<BigDecimal>> fees = cities.stream()
.map(city -> CompletableFuture.supplyAsync(() -> shippingService.feeFor(city), executor))
.toList();
CompletableFuture<List<BigDecimal>> allFees = CompletableFuture
.allOf(fees.toArray(CompletableFuture[]::new))
.thenApply(ignored -> fees.stream()
.map(CompletableFuture::join)
.toList());
System.out.println(allFees.join());
[30000, 45000, 45000]
Inside thenApply, all child futures have already completed. So join() returns immediately without blocking. The results keep the same order as in cities.
5.3. anyOf to Get the Fastest Result
anyOf completes as soon as any one child future completes. This is useful when you call several replicas of the same service:
CompletableFuture<Object> fastest = CompletableFuture.anyOf(
CompletableFuture.supplyAsync(() -> { Delay.sleep(500); return "replica-A"; }, executor),
CompletableFuture.supplyAsync(() -> { Delay.sleep(100); return "replica-B"; }, executor));
System.out.println(fastest.join()); // replica-B
The return type is CompletableFuture<Object>, so you must cast it yourself. Note also that if the fastest future finishes with an error, anyOf finishes with that same error.
6. Error Handling and Timeouts
6.1. exceptionally and handle
An exception in any step propagates down to the later steps. Those steps are skipped until you reach an error-handling step:
CompletableFuture<BigDecimal> fee = CompletableFuture
.supplyAsync(() -> {
throw new IllegalStateException("Shipping service down");
}, executor)
.thenApply(value -> (BigDecimal) value) // skipped
.exceptionally(ex -> {
System.out.println("Error: " + ex.getMessage());
return new BigDecimal("50000"); // fallback value
});
System.out.println(fee.join());
Error: java.lang.IllegalStateException: Shipping service down
50000
The parameter ex is usually a CompletionException wrapping the original exception. That is why the message starts with the class name. Use ex.getCause() to get the actual error.
handle receives both the result and the error. It is always called, whether the task succeeds or fails:
CompletableFuture<String> status = CompletableFuture
.supplyAsync(() -> orderService.findByCustomer(42L), executor)
.handle((orderList, ex) -> ex == null
? "Found " + orderList.size() + " orders"
: "Could not load orders");
There is also whenComplete. It too receives both the result and the error, but it does not change the result. This method is suitable for logging.
6.2. orTimeout and completeOnTimeout
Never wait for an external service forever. Java 9+ provides two methods for this:
import java.util.concurrent.TimeUnit;
CompletableFuture<BigDecimal> withDefault = CompletableFuture
.supplyAsync(() -> { Delay.sleep(2000); return new BigDecimal("30000"); }, executor)
.completeOnTimeout(new BigDecimal("45000"), 500, TimeUnit.MILLISECONDS);
System.out.println(withDefault.join()); // 45000 after ~500 ms
CompletableFuture<BigDecimal> strict = CompletableFuture
.supplyAsync(() -> { Delay.sleep(2000); return new BigDecimal("30000"); }, executor)
.orTimeout(500, TimeUnit.MILLISECONDS);
// strict.join() throws CompletionException whose cause is TimeoutException
completeOnTimeout returns the default value when time runs out. orTimeout completes the future exceptionally with a TimeoutException.
Note: A timeout only completes the future. It does not stop the underlying task. The thread keeps “sleeping” for 2 seconds.
cancel(true)does not interrupt a running thread either. If you need real cancellation, you must check a cancel flag inside the task yourself, or use the timeout built into your HTTP client.
7. Writing Tests
If you use Spring Boot, spring-boot-starter-test already includes JUnit 5 and AssertJ. For a plain Java project, add:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
First, move the logic into a class so it is easy to test:
class CheckoutService {
private final OrderService orderService;
private final ShippingService shippingService;
private final Executor executor;
CheckoutService(OrderService o, ShippingService s, Executor executor) {
this.orderService = o;
this.shippingService = s;
this.executor = executor;
}
CompletableFuture<BigDecimal> grandTotal(long customerId, String city) {
var orders = CompletableFuture.supplyAsync(() -> orderService.findByCustomer(customerId), executor);
var fee = CompletableFuture.supplyAsync(() -> shippingService.feeFor(city), executor);
return orders.thenCombine(fee, (list, f) -> list.stream()
.map(Order::total)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.add(f));
}
}
Then write tests for both the success and failure cases:
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.Test;
class CheckoutServiceTest {
@Test
void shouldCombineOrdersAndShippingFee() {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var service = new CheckoutService(new OrderService(), new ShippingService(), executor);
assertThat(service.grandTotal(42L, "Hue"))
.succeedsWithin(Duration.ofSeconds(2))
.isEqualTo(new BigDecimal("270000"));
}
}
@Test
void shouldPropagateShippingFailure() {
ShippingService broken = new ShippingService() {
@Override
BigDecimal feeFor(String city) {
throw new IllegalStateException("down");
}
};
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var service = new CheckoutService(new OrderService(), broken, executor);
assertThatThrownBy(() -> service.grandTotal(42L, "Hue").join())
.isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(IllegalStateException.class);
}
}
}
succeedsWithin waits up to 2 seconds and then checks the value. This way the test won’t hang if the code has a bug. In the second test, the error from ShippingService propagates through thenCombine and is wrapped in a CompletionException.
In tests, you can pass Runnable::run as the Executor so everything runs synchronously on the test thread. This keeps tests fast and their results stable.
8. Common Mistakes
- Running I/O on
commonPool. This pool has one fewer thread than the number of CPUs and is shared across the whole application (including parallel streams). A few slow HTTP calls can clog it completely. For I/O, pass a dedicated executor, ideally one backed by virtual threads. - Calling
join()too early. WritingsupplyAsync(a).join()and thensupplyAsync(b).join()is still sequential. Start all futures first, and only then combine or wait for them. - Swallowing exceptions. If nobody calls
join(),get(), or attachesexceptionally/whenComplete, the error disappears without a trace. - Confusing
thenApplywiththenApplyAsync.thenApplymay run on the thread that completed the previous step, or on the calling thread if the future is already done. If the next step is heavy or blocking, use the...Async(fn, executor)variant. - Not setting timeouts. A hung service will leave your request hanging too.
- Exiting early. Threads in
commonPoolare daemon threads. Ifmainends before the tasks finish, the results will never be printed.
Note: Don’t use
CompletableFuturewhen you have a single call and you wait for its result right away. From Java 21 onward, with code running on virtual threads, plain synchronous code is often easier to read without losing performance.CompletableFutureis worth using when you need to run several independent tasks in parallel, or when you need a non-blocking pipeline.
9. Conclusion
CompletableFuture lets you run independent tasks in parallel and combine their results without managing threads yourself. A quick summary:
- Creating tasks:
supplyAsync/runAsync, always passing a suitable executor. - Chaining steps:
thenApplywhen transforming a value,thenComposewhen the next step returns a future. - Combining:
thenCombinefor two futures,allOffor many futures,anyOfto get the fastest result. - Errors and timeouts:
exceptionally,handle,orTimeout,completeOnTimeout.
In tests, use AssertJ’s succeedsWithin to check results without worrying about tests hanging.