1. thenCompose vs. thenApply: the difference and when to use which
In asynchronous programming in Java (via CompletableFuture), you often need to execute chains of actions. There are two similar methods: thenApply and thenCompose. But they behave differently!
thenApply
The thenApply method is used when the next step is a simple value transformation, without starting new asynchronous operations. It takes the result of the previous step, processes it, and returns a new value (not a CompletableFuture).
If you're familiar with the Stream API, then thenApply behaves roughly like map: it takes the result, applies a function, and returns the transformed value.
Example:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> "42");
CompletableFuture<Integer> lengthFuture = cf.thenApply(s -> s.length());
// lengthFuture contains 2 (the length of the string "42")
Put simply, thenApply is a way to say: “When the result is ready, do this with it.”
thenCompose
- Used when the next step is another asynchronous operation (returns a CompletableFuture).
- Allows you to “unwrap” nested CompletableFutures (analogous to flatMap).
- If you use thenApply with an asynchronous function, you'll get CompletableFuture<CompletableFuture<T>> — inconvenient!
Example:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> "user42");
// Suppose we need to get a user's orders by username (asynchronously)
CompletableFuture<List<Order>> ordersFuture = cf.thenCompose(username -> fetchOrdersAsync(username));
// fetchOrdersAsync returns CompletableFuture<List<Order>>
Visually:
- thenApply: CF<String> → thenApply(s -> s.length()) → CF<Integer>
- thenCompose: CF<User> → thenCompose(u -> fetchOrdersAsync(u.id)) → CF<List<Order>>
When to use which?
- The function returns a plain value — use thenApply.
- The function returns a CompletableFuture — use thenCompose.
Example mistake:
cf.thenApply(username -> fetchOrdersAsync(username)); // You get CF<CF<List<Order>>>
cf.thenCompose(username -> fetchOrdersAsync(username)); // You get CF<List<Order>>
2. Managing the thread pool (Executor): why and how to use your own Executor
Default: ForkJoinPool.commonPool()
When you write CompletableFuture.supplyAsync(...) or thenApplyAsync(...) without specifying an Executor, Java uses the shared thread pool — ForkJoinPool.commonPool(). That's convenient, but not always appropriate:
- If you have many long or blocking operations (network calls, file I/O), the common pool can get clogged and all tasks will have to wait.
- Sometimes you need to isolate tasks with different priorities or limit the number of concurrently running threads.
When do you need your own Executor?
- Long, blocking operations (for example, DB queries, HTTP requests, file reads).
- Task isolation: so that user-facing tasks don't interfere with system tasks.
- Resource limiting: for example, do not start more than 10 concurrent downloads.
How to create your own Executor
Typically you use a ThreadPoolExecutor or the factories from Executors:
ExecutorService myExecutor = Executors.newFixedThreadPool(10);
How to use your Executor with CompletableFuture
- In methods supplyAsync, runAsync, thenApplyAsync, thenComposeAsync, and others, you can pass a second argument — your Executor.
Examples:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(
() -> loadDataFromNetwork(), myExecutor
);
cf.thenApplyAsync(data -> processData(data), myExecutor)
.thenAcceptAsync(result -> System.out.println(result), myExecutor);
Important: if you don't specify an Executor, ForkJoinPool.commonPool() will be used.
When is the default Executor enough?
- For short, CPU-bound tasks (simple computations).
- When it doesn't matter which thread the task runs on.
3. Handling timeouts: orTimeout and completeOnTimeout
Asynchronous operations can hang or take too long (for example, if a server doesn't respond). To avoid waiting forever, CompletableFuture provides methods for timeouts.
orTimeout
- Completes the CompletableFuture with a TimeoutException if the operation doesn't finish within the specified time.
- Does not cancel the task that's actually running, but the downstream chain will receive an error.
Syntax:
cf.orTimeout(3, TimeUnit.SECONDS)
.exceptionally(ex -> {
System.out.println("Timeout: " + ex);
return null;
});
Example:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
Thread.sleep(5000); // simulate a long operation
return "OK";
});
cf.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> {
System.out.println("Error: " + ex);
return "TIMEOUT";
});
Result:
After 2 seconds a TimeoutException will be thrown, and exceptionally will handle the error.
completeOnTimeout
- Completes the CompletableFuture with the specified value if the operation doesn't finish before the timeout.
- Doesn't throw an exception; returns a “fallback” value.
Syntax:
cf.completeOnTimeout("DEFAULT", 2, TimeUnit.SECONDS);
Example:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
Thread.sleep(5000);
return "OK";
});
cf.completeOnTimeout("TIMEOUT", 2, TimeUnit.SECONDS)
.thenAccept(System.out::println); // After 2 seconds it will print "TIMEOUT"
Comparing orTimeout and completeOnTimeout
| Method | What happens on timeout? | How is it handled downstream? |
|---|---|---|
|
Completes with TimeoutException | Can be handled via exceptionally/handle |
|
Completes with the specified value | thenAccept/thenApply will receive that value |
4. Practice: example with thenCompose, a custom Executor, and a timeout
Task:
- Fetch a user by id (asynchronously, with a delay).
- Then asynchronously fetch the user's orders (also with a delay).
- Use a custom Executor.
- Add a timeout for fetching orders.
import java.util.concurrent.*;
import java.util.*;
public class AsyncDemo {
static ExecutorService ioExecutor = Executors.newFixedThreadPool(4);
// Simulate asynchronous user retrieval
static CompletableFuture<String> fetchUserAsync(int userId) {
return CompletableFuture.supplyAsync(() -> {
sleep(1000);
return "user" + userId;
}, ioExecutor);
}
// Simulate asynchronous retrieval of user orders
static CompletableFuture<List<String>> fetchOrdersAsync(String username) {
return CompletableFuture.supplyAsync(() -> {
sleep(3000); // Long operation!
return List.of("order1", "order2");
}, ioExecutor);
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
public static void main(String[] args) {
fetchUserAsync(42)
.thenCompose(username ->
fetchOrdersAsync(username)
.orTimeout(2, TimeUnit.SECONDS) // Timeout when fetching orders
.exceptionally(ex -> {
System.out.println("Failed to fetch orders: " + ex);
return List.of();
})
)
.thenAccept(orders -> System.out.println("Orders: " + orders))
.join(); // Wait for the entire chain to complete
ioExecutor.shutdown();
}
}
What happens:
- Fetch the user (1 second).
- Fetch the orders (3 seconds, but timeout is 2 seconds).
- If it doesn't finish in time — we catch a TimeoutException and return an empty list.
- Everything runs on the custom Executor.
Output:
Failed to fetch orders: java.util.concurrent.TimeoutException
Orders: []
If you reduce the delay in fetchOrdersAsync to 1_000 ms, you'll see the actual orders.
5. Common mistakes and nuances
Mistake No. 1: Using thenApply instead of thenCompose for asynchronous operations.
If a function returns a CompletableFuture and you use thenApply, you'll get the nested type CompletableFuture<CompletableFuture<T>>. This complicates the chain and adds unnecessary wrappers. Solution: use thenCompose to flatten the result into a CompletableFuture<T>.
Mistake No. 2: Running long or I/O tasks without a dedicated Executor.
By default, tasks run in ForkJoinPool.commonPool(). If you overload it, latencies will grow, and other tasks in the application may slow down. Solution: create your own ExecutorService and pass it to supplyAsync/thenApplyAsync.
Mistake No. 3: Expecting orTimeout to cancel the task.
orTimeout only completes the CompletableFuture with a timeout exception; the task itself keeps running in the background. Solution: if you need to stop execution, use cancel(true) or your own interruption mechanisms.
Mistake No. 4: Misunderstanding the scope of a timeout.
orTimeout and completeOnTimeout apply only to a specific step in the chain, not the entire chain. Solution: if you need a global timeout for the whole chain, wrap it in a separate CompletableFuture and apply the timeout to that.
Mistake No. 5: Not shutting down ExecutorService.
If you don't call shutdown()/shutdownNow() on the ExecutorService after tasks finish, threads will keep running, and the program may hang. Solution: always close the ExecutorService in finally or use try-with-resources in Java 21+.
GO TO FULL VERSION