CompletableFuture & Async Composition
Compose asynchronous workflows cleanly, with explicit timeout and failure behavior.
📌 Key Idea
CompletableFuture is Java's promise-style API for async orchestration. Use thenCompose for dependent calls, thenCombine/allOf for parallel fan-out, and always define executor, timeout, and fallback strategy.Problem it solves
CompletableFuture solves the problem of slow, sequential waiting between independent I/O calls. Instead of blocking one call after another, it lets you run them concurrently and compose results in a readable pipeline.
Why It Matters
- Latency reduction — Parallel downstream calls can significantly reduce end-to-end response time.
- Readable async flow — Avoids callback pyramids with explicit composition primitives.
- Controlled failure behavior — Timeouts and fallbacks can be attached directly to stages.
When to Use
- Parallel downstream calls in service aggregation APIs.
- Non-blocking orchestration where end-to-end latency matters.
- Structured async flows with fallback/timeout behavior.
When NOT to Use
- Simple sequential code where async adds complexity.
- Blocking
.get()everywhere (defeats async benefit).
Code Variations
0️⃣ Problem it solves (sequential wait vs async composition)java
// ❌ Sequential: total latency ~= A + B
User user = userClient.fetch(userId); // 120ms
List<Order> orders = orderClient.fetch(userId); // 150ms
Dashboard d1 = new Dashboard(user, orders); // ~270ms+
// ✅ CompletableFuture: run in parallel, then combine
CompletableFuture<User> userF = CompletableFuture.supplyAsync(
() -> userClient.fetch(userId)
);
CompletableFuture<List<Order>> ordersF = CompletableFuture.supplyAsync(
() -> orderClient.fetch(userId)
);
Dashboard d2 = userF.thenCombine(ordersF, Dashboard::new).join(); // ~max(A,B)Simple rule: if calls are independent and I/O-bound, compose asynchronously instead of waiting sequentially.
1️⃣ Dependent async chain (
thenCompose)javaCompletableFuture<User> f = fetchUser(id)
.thenCompose(user -> fetchProfile(user.profileId()));Use
thenCompose to flatten future-of-future.2️⃣ Fan-out + combine + timeoutjava
CompletableFuture<A> a = callA();
CompletableFuture<B> b = callB();
return a.thenCombine(b, this::merge)
.orTimeout(300, MILLISECONDS)
.exceptionally(ex -> fallback());Always define timeout/error policy for remote calls.
3️⃣ Aggregation endpoint examplejava
CompletableFuture<User> userF = userClient.fetch(userId);
CompletableFuture<List<Order>> ordersF = orderClient.fetchRecent(userId);
CompletableFuture<CreditScore> scoreF = riskClient.fetchScore(userId);
return CompletableFuture.allOf(userF, ordersF, scoreF)
.thenApply(v -> new Dashboard(userF.join(), ordersF.join(), scoreF.join()))
.orTimeout(500, MILLISECONDS);Use
join() only after allOf completion inside the async pipeline.4️⃣ Custom executor (avoid commonPool surprises)java
ExecutorService ioPool = Executors.newFixedThreadPool(32);
CompletableFuture<User> f = CompletableFuture.supplyAsync(
() -> userRepo.load(id), ioPool
);Explicit executors prevent contention with unrelated workloads on
ForkJoinPool.commonPool().Core CompletableFuture operations
| Aspect | Method | Use when | Returns |
|---|---|---|---|
thenApply(fn) | Transform value synchronously | CompletableFuture<U> | |
thenCompose(fn) | Next step itself is async | Flattened CompletableFuture<U> | |
thenCombine(other, fn) | Merge two independent futures | CompletableFuture<U> | |
allOf(f1,f2,...) | Wait for many futures | CompletableFuture<Void> | |
exceptionally(fn) | Map failure to fallback value | CompletableFuture<T> |
Common Confusions Cleared Up
thenApply vs thenCompose
thenApply maps value; thenCompose chains another async future and flattens.
join vs get
join() throws unchecked CompletionException; get() throws checked exceptions and often encourages blocking patterns.
Interview Q&A
Quick Cheat Sheet
Dependent chain
thenComposeParallel combine
thenCombine, allOfSafetytimeouts + exception handling + bounded executors
PerformanceAvoid blocking
.get()/.join() on request threads