10 CompletableFuture Patterns Every Java Developer Should Know for Async Code
Master Java's CompletableFuture with 10 practical async patterns — from chaining and combining futures to timeouts, error recovery, and virtual threads. Write cleaner, faster code today.
I want to talk about a tool that changed how I write asynchronous code in Java. It’s called CompletableFuture. When I first saw it, I was confused. I had been using Future and callbacks, and my code looked like a mess of nested anonymous classes. Then I learned a few simple patterns, and everything clicked.
Asynchronous programming does not have to be hard. You just need to know a handful of moves. I will walk you through ten patterns I use almost every day. I will show you code, explain why it works, and tell you what mistakes I made so you can avoid them.
Creating and Completing Futures Explicitly
The starting point is simple. You want to run a task in the background and get its result later. You can call CompletableFuture.supplyAsync() and give it a piece of work that returns a value. Or you can call runAsync() if you don’t care about the return value.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return fetchDataFromRemoteService();
});
The method above uses the common ForkJoinPool. That is fine for CPU‑light work, but if your task blocks on I/O, you should pass your own executor. I keep a dedicated thread pool for database calls.
Sometimes you need to build a future yourself, especially when you talk to older APIs that use callbacks. You create an empty CompletableFuture and call complete() later.
CompletableFuture<String> manualFuture = new CompletableFuture<>();
executor.submit(() -> {
try {
String result = someBlockingCall();
manualFuture.complete(result);
} catch (Exception e) {
manualFuture.completeExceptionally(e);
}
});
This pattern is helpful when you adapt a library that does not return a future. I once had to wrap a file‑reading callback. Creating a CompletableFuture manually gave me a clean way to convert that callback into a promise.
Chaining Dependent Actions with thenApply and thenCompose
Once you have a future, you often want to transform its result. If the transformation is quick, use thenApply. It runs on the same thread that completed the future, so it is cheap.
CompletableFuture<String> greeting = CompletableFuture
.supplyAsync(() -> "Hello")
.thenApply(greeting -> greeting + " World");
If the transformation itself is asynchronous, you need thenCompose. It returns a new future, and you avoid ending up with a CompletableFuture<CompletableFuture>.
CompletableFuture<Integer> length = CompletableFuture
.supplyAsync(() -> getNameFromDatabase())
.thenCompose(name -> CompletableFuture.supplyAsync(() -> name.length()));
I stumbled on this pattern after nesting futures and then calling .get() twice. It looked ugly. thenCompose flattened the structure. Think of it like flatMap in streams.
Combining Two Independent Futures with thenCombine
Often you need data from two separate sources before you can do something. You can fetch both in parallel and then combine the results. thenCombine does exactly that.
CompletableFuture<String> userFuture = getUser(userId);
CompletableFuture<String> orderFuture = getOrder(orderId);
CompletableFuture<String> combined = userFuture.thenCombine(orderFuture,
(user, order) -> "User: " + user + ", Order: " + order);
Both futures start immediately. The combined future finishes when both have completed. I use this pattern in my REST endpoints. I fetch user profile and user settings at the same time, then build a response. It cuts waiting time in half.
Running Multiple Futures in Parallel and Waiting for All
When you have a list of tasks, and you need all results, do not call them one by one. Submit them all at once, then use allOf to wait until every single one is done.
List<CompletableFuture<String>> futures = ids.stream()
.map(id -> CompletableFuture.supplyAsync(() -> fetchDataById(id)))
.toList();
CompletableFuture<Void> allDone = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0]));
CompletableFuture<List<String>> allResults = allDone.thenApply(v ->
futures.stream()
.map(CompletableFuture::join)
.toList());
Notice that I use join() inside the callback. join() is like get() but does not throw a checked exception, so it is cleaner inside a lambda. I once had a bug because I used get() inside a stream and forgot to catch InterruptedException. Use join() for that.
Handling Timeouts with orTimeout and completeOnTimeout
A future that never completes is worse than a failed future. It can hang your application forever. Java 9 added two methods to set timeouts directly on a CompletableFuture. I always use them when my future calls an external service.
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> slowService())
.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> "Timeout occurred");
orTimeout throws a TimeoutException. You catch it with exceptionally and return a default value.
If you prefer a default value instead of an exception, use completeOnTimeout.
CompletableFuture<String> withDefault = CompletableFuture
.supplyAsync(() -> slowService())
.completeOnTimeout("default", 2, TimeUnit.SECONDS);
I learned the hard way that without a timeout, a slow database query could freeze my whole thread pool. Now I never start an async task without an upper limit.
Recovering from Errors with exceptionally and handle
Errors happen. Your remote service goes down, or a timeout kicks in. You need to recover gracefully. exceptionally lets you replace an exception with a fallback value.
CompletableFuture<String> safeFuture = CompletableFuture
.supplyAsync(() -> riskyOperation())
.exceptionally(throwable -> {
log.error("Operation failed", throwable);
return "fallback";
});
If you need to decide based on whether the original value exists or not, use handle. It receives both the result and the exception.
CompletableFuture<String> handled = CompletableFuture
.supplyAsync(() -> riskyOperation())
.handle((result, ex) -> {
if (ex != null) {
return "error: " + ex.getMessage();
}
return result;
});
I often use handle in logging scenarios. I log the exception and still return a reasonable response. The caller never sees the raw exception.
Applying Timeouts Per Subtask in a Chain
Sometimes you have a chain of operations, and each step has its own timeout requirement. You do not want one slow step to delay the whole chain. You can apply orTimeout after each step.
CompletableFuture<String> result = CompletableFuture
.supplyAsync(() -> stepOne())
.orTimeout(1, TimeUnit.SECONDS)
.thenApply(s -> s + " processed")
.thenCompose(s -> CompletableFuture.supplyAsync(() -> stepTwo(s)))
.orTimeout(2, TimeUnit.SECONDS);
Here, stepOne has one second to return, and stepTwo has two seconds. If stepOne times out, the whole chain fails early. I use this pattern in services where different external APIs have different SLAs. It prevents a single slow service from consuming all the allocated time.
Using Executors with Virtual Threads for Scalable Concurrency
Java 21 brought virtual threads. They make asynchronous code simpler because you can write blocking code without worrying about thread starvation. I now use a virtual thread executor for my CompletableFutures.
Executor virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture.supplyAsync(() -> blockingDatabaseCall(), virtualThreadExecutor)
.thenApplyAsync(result -> transform(result), virtualThreadExecutor);
Before virtual threads, I had to be careful not to block the common ForkJoinPool. Now I can call blocking methods inside my suppliers, and the runtime handles the rest. The code looks synchronous but runs concurrently. It changed how I design services.
Converting Between CompletableFuture and Other Async Types
You might work with reactive libraries like Project Reactor or RxJava. Often you need to pass results between them. The conversion is built‑in.
// From Mono to CompletableFuture
Mono<String> mono = webClient.get().retrieve().bodyToMono(String.class);
CompletableFuture<String> future = mono.toFuture();
// From CompletableFuture to Mono
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> result);
Mono<String> mono = Mono.fromFuture(future);
I use this when my team standardises on CompletableFuture for orchestration but the HTTP client returns a Mono. The conversion is zero‑cost. No threads are wasted.
Testing Asynchronous Code with CompletableFuture
Testing asynchronous code used to be a pain. You would add Thread.sleep() and hope the test passed. Now I use CompletableFuture methods to control time in tests.
@Test
void testServiceWithMock() {
CompletableFuture<String> mockFuture = new CompletableFuture<>();
when(service.call()).thenReturn(mockFuture);
// Trigger the service method asynchronously in test
CompletableFuture<String> result = service.process();
// Simulate completion
mockFuture.complete("expected");
assertEquals("expected", result.get(1, TimeUnit.SECONDS));
}
I create a mock future and complete it on demand. No sleeps. I can test timeouts by using orTimeout on the future and verifying the exception.
@Test
void testTimeout() {
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> {
sleep(5000);
return "done";
})
.orTimeout(1, TimeUnit.SECONDS);
assertThrows(TimeoutException.class, () -> future.get(2, TimeUnit.SECONDS));
}
These tests run fast and are reliable. I stopped writing flaky tests once I started using CompletableFuture directly in test code.
This is my toolbox. Ten patterns that cover most of the asynchronous problems you will face. I use them every day. They are not hard. You just need to practice each one until it becomes automatic. Start with creating futures, then add chaining, then try allOf. Before you know it, you will be composing complex workflows without breaking a sweat.
Asynchronous programming in Java is no longer mysterious. It is just patterns. Learn them, and you will write code that is faster and easier to understand.