LLD
Machine Coding
Interview Course
Java โ€ข Interview Prep
๐Ÿ”„ Concurrency Deep Dive/

CompletableFuture: Async Without the Pain

Lesson 8 of 8

CompletableFuture: Async Without the Pain


Before We Start โ€” What You Need to Know

By now you've learned several ways to manage threads: confinement, atomics, semaphores, conditions, and executors. But there's a common pattern we haven't addressed: chaining asynchronous operations together.

Imagine calling a REST API, then using the result to query a database, then transforming the output, then sending a notification. Each step is asynchronous (runs on a background thread). With raw threads and Future, this becomes a nested nightmare of callbacks and blocking calls. CompletableFuture is Java's answer โ€” it lets you compose async operations like LEGO blocks, in a clean, readable pipeline.

You should know: what Runnable and Callable are, what Future is (a handle to a result that's not ready yet), and that future.get() blocks until the result is available.


What is it? (The Analogy)

Think of ordering food through a delivery app:

  1. You place an order (start an async task). The app gives you a tracking screen โ€” that's your CompletableFuture.
  2. When the restaurant finishes cooking (thenApply), the app automatically updates to "Driver picking up."
  3. When the driver picks it up (thenApply), it updates to "On the way."
  4. When it arrives (thenAccept), you get a notification.
  5. If anything goes wrong at any step (exceptionally), you get a refund.

You didn't have to stand at the restaurant door waiting (blocking). You didn't have to set up a complex callback system. The delivery app *chained* each step declaratively: "When cooking is done, do this. When pickup is done, do that. If anything fails, do this."

That's CompletableFuture. It's a promise of a future result that supports chaining, combining, and error handling โ€” all without blocking threads.


The Problem It Solves

Here's the old way with raw Future:

java
1import java.util.concurrent.*;
2
3// PAINFUL: Blocking futures and nested callbacks
4class OldStyleService {
5    ExecutorService pool = Executors.newFixedThreadPool(4);
6
7    public String processOrder(String orderId) throws Exception {
8        // Step 1: Fetch order from DB (async)
9        Future<String> orderFuture = pool.submit(
10            () -> fetchOrderFromDB(orderId)
11        );
12
13        // BLOCKS here until DB query completes โ€” thread wasted!
14        String orderData = orderFuture.get();
15
16        // Step 2: Calculate price (async)
17        Future<Double> priceFuture = pool.submit(
18            () -> calculatePrice(orderData)
19        );
20
21        // BLOCKS AGAIN โ€” another thread wasted!
22        double price = priceFuture.get();
23
24        // Step 3: Send confirmation (async)
25        Future<String> confirmFuture = pool.submit(
26            () -> sendConfirmation(orderId, price)
27        );
28
29        // BLOCKS A THIRD TIME!
30        return confirmFuture.get();
31    }
32
33    // Each .get() blocks the calling thread, turning
34    // "async" code into sequential code that wastes threads!
35}

What's wrong?

  • Each .get() blocks the calling thread until the result is ready.
  • We have 3 sequential async operations, but the calling thread is *stuck waiting* at each step. We might as well have written synchronous code!
  • No clean error handling โ€” each .get() can throw checked ExecutionException, leading to ugly try-catch blocks.
  • What if Step 2 doesn't depend on Step 1? With Future, there's no easy way to express "run these two in parallel, then combine results."

How it works โ€” Step by Step

  1. Start an async task: CompletableFuture.supplyAsync(() -> fetchOrder()) runs the lambda on a background thread and returns a CompletableFuture<Order>.
  2. Chain a transformation: .thenApply(order -> calculatePrice(order)) โ€” when the order is ready, compute the price on the same thread (or a pool thread). Returns a new CompletableFuture<Double>.
  3. Chain a side effect: .thenAccept(price -> sendConfirmation(price)) โ€” when the price is ready, send a notification. Doesn't produce a new result.
  4. Handle errors: .exceptionally(ex -> handleError(ex)) โ€” if ANY step in the chain fails, this catch block runs.
  5. Combine two futures: future1.thenCombine(future2, (a, b) -> merge(a, b)) โ€” wait for both to complete, then merge results.
  6. Race two futures: CompletableFuture.anyOf(f1, f2, f3) โ€” returns as soon as the first one completes.
  7. Fan-out and gather: CompletableFuture.allOf(f1, f2, f3) โ€” returns when all complete.

Key methods cheat sheet:

java
1supplyAsync(supplier)      โ€” start an async computation that returns a value
2thenApply(fn)              โ€” transform the result (like .map in streams)
3thenAccept(consumer)       โ€” consume the result (no return value)
4thenCompose(fn)            โ€” chain another async operation (like .flatMap)
5thenCombine(other, fn)     โ€” combine two futures' results
6exceptionally(fn)          โ€” handle errors
7handle(fn)                 โ€” handle both success and error
8thenRun(runnable)          โ€” run something after completion (no access to result)

Let's Build It Together

Let's build an async food delivery pipeline โ€” from order to delivery:

java
1import java.util.concurrent.CompletableFuture;
2import java.util.concurrent.ExecutorService;
3import java.util.concurrent.Executors;
4import java.util.concurrent.TimeUnit;
5
6public class FoodDeliveryPipeline {
7
8    // Custom thread pool (don't rely on ForkJoinPool for I/O tasks)
9    private static final ExecutorService ioPool =
10        Executors.newFixedThreadPool(8);
11
12    /**
13     * Process a food delivery order โ€” fully async, no blocking!
14     */
15    public static CompletableFuture<String> processDelivery(String orderId) {
16
17        return CompletableFuture
18
19            // STEP 1: Validate the order (async, on our I/O pool)
20            .supplyAsync(() -> {
21                System.out.println("[" + Thread.currentThread().getName()
22                    + "] Validating order " + orderId);
23                simulateWork(300);
24                return "Order " + orderId + " validated";
25            }, ioPool)
26
27            // STEP 2: Calculate price (transforms the result)
28            .thenApply(validationResult -> {
29                System.out.println("[" + Thread.currentThread().getName()
30                    + "] Calculating price for " + orderId);
31                simulateWork(200);
32                double price = 12.99 + Math.random() * 10;
33                return String.format("%.2f", price);
34            })
35
36            // STEP 3: Charge payment (another async operation โ€” use thenCompose!)
37            .thenCompose(price -> {
38                // thenCompose is like flatMap โ€” it unwraps the nested future
39                return CompletableFuture.supplyAsync(() -> {
40                    System.out.println("[" + Thread.currentThread().getName()
41                        + "] Charging $" + price + " for " + orderId);
42                    simulateWork(500);
43                    return "Payment of $" + price + " confirmed";
44                }, ioPool);
45            })
46
47            // STEP 4: Assign a driver (after payment)
48            .thenApply(paymentResult -> {
49                System.out.println("[" + Thread.currentThread().getName()
50                    + "] Assigning driver for " + orderId);
51                simulateWork(400);
52                String[] drivers = {"Alex", "Jordan", "Sam", "Taylor"};
53                String driver = drivers[(int) (Math.random() * drivers.length)];
54                return "Driver " + driver + " assigned for " + orderId;
55            })
56
57            // STEP 5: Handle errors at ANY stage
58            .exceptionally(ex -> {
59                System.err.println("Delivery failed for " + orderId
60                    + ": " + ex.getMessage());
61                return "REFUND issued for " + orderId;
62            });
63    }
64
65    /**
66     * Demonstrate combining multiple independent futures.
67     */
68    public static CompletableFuture<String> processOrderWithSideDishes(
69            String mainDish, String sideDish) {
70
71        // These two run IN PARALLEL โ€” no dependency between them!
72        CompletableFuture<String> mainFuture = CompletableFuture
73            .supplyAsync(() -> {
74                System.out.println("Preparing main: " + mainDish);
75                simulateWork(800);
76                return mainDish + " (ready)";
77            }, ioPool);
78
79        CompletableFuture<String> sideFuture = CompletableFuture
80            .supplyAsync(() -> {
81                System.out.println("Preparing side: " + sideDish);
82                simulateWork(500);
83                return sideDish + " (ready)";
84            }, ioPool);
85
86        // Combine when BOTH are done
87        return mainFuture.thenCombine(sideFuture, (main, side) -> {
88            System.out.println("Both dishes ready! Packing...");
89            return "Packed: " + main + " + " + side;
90        });
91    }
92
93    /**
94     * Fan-out: process multiple orders in parallel, gather all results.
95     */
96    public static CompletableFuture<Void> processMultipleOrders(
97            String... orderIds) {
98
99        // Start ALL orders in parallel
100        CompletableFuture<String>[] futures = new CompletableFuture[orderIds.length];
101        for (int i = 0; i < orderIds.length; i++) {
102            futures[i] = processDelivery(orderIds[i]);
103        }
104
105        // Wait for ALL to complete
106        return CompletableFuture.allOf(futures)
107            .thenRun(() -> {
108                System.out.println("\n=== ALL ORDERS PROCESSED ===");
109                for (int i = 0; i < futures.length; i++) {
110                    // .join() won't block here because allOf already waited
111                    System.out.println(orderIds[i] + ": " + futures[i].join());
112                }
113            });
114    }
115
116    private static void simulateWork(long millis) {
117        try { Thread.sleep(millis); } catch (InterruptedException e) {
118            Thread.currentThread().interrupt();
119        }
120    }
121
122    public static void main(String[] args) throws Exception {
123        System.out.println("--- Single order pipeline ---");
124        String result = processDelivery("ORD-001").join(); // .join() blocks here
125        System.out.println("Result: " + result);
126
127        System.out.println("\n--- Parallel main + side dish ---");
128        String combo = processOrderWithSideDishes("Pizza", "Garlic Bread").join();
129        System.out.println("Result: " + combo);
130
131        System.out.println("\n--- Fan-out: 3 orders in parallel ---");
132        processMultipleOrders("ORD-100", "ORD-101", "ORD-102").join();
133
134        ioPool.shutdown();
135        ioPool.awaitTermination(30, TimeUnit.SECONDS);
136    }
137}

What Happens Under the Hood

Let's trace what happens when you write:

java
CompletableFuture.supplyAsync(() -> fetchOrder(), ioPool)
    .thenApply(order -> calculatePrice(order))
    .thenAccept(price -> notify(price));
  1. **supplyAsync**: Submits the fetchOrder() lambda to the ioPool executor. Returns a CompletableFuture<Order> that is currently incomplete.
  1. **thenApply**: Does NOT run calculatePrice yet! It registers a callback (called a "completion handler") on the future from step 1. It returns a *new* CompletableFuture<Double> that will complete when the callback finishes.
  1. **thenAccept**: Registers another callback on the future from step 2.
  1. **When fetchOrder() completes on a pool thread**: That thread (or a different pool thread, depending on the variant) runs the thenApply callback, producing a price.
  1. **When calculatePrice completes**: The thenAccept callback runs, calling notify().

The key insight: No thread is blocked waiting. The callbacks are triggered automatically by whichever thread completes the previous stage. It's an event-driven model internally โ€” each CompletableFuture maintains a stack of dependent actions that fire when it completes.

**thenApply vs thenApplyAsync**: The non-async variant runs the callback on the *same thread* that completed the previous stage. The *Async variant submits the callback to the executor as a new task. Use *Async when the callback is CPU-heavy and you don't want to block the completing thread.

**Danger zone: The default executor is ForkJoinPool.commonPool()!** If you don't specify an executor, supplyAsync and all *Async variants use the common pool. This pool is sized for CPU-bound tasks (typically = number of cores). If you submit blocking I/O tasks (HTTP calls, DB queries) to it, you can exhaust the pool and starve the entire application. Always pass a dedicated I/O executor for blocking operations.


When to Use vs When NOT to Use

Use CompletableFuture WhenDon't Use When
Chaining async operations (API -> DB -> notification)You have a single synchronous operation (just call it directly)
Running independent tasks in parallel and combining resultsAll tasks are CPU-bound and you're already in a parallel stream
Building non-blocking I/O pipelinesThe code is simple enough that Future.get() isn't a problem
Fan-out/fan-in patterns (call 5 services, merge results)You need precise thread control (use raw executors or actors)
Timeout handling (orTimeout, completeOnTimeout in Java 9+)You're in a reactive framework that has its own async primitives (Reactor, RxJava)
Error recovery (exceptionally, handle)

Common Mistakes & Gotchas

  • Not specifying a custom executor for I/O tasks. Using the default ForkJoinPool.commonPool() for blocking calls (HTTP, DB, file I/O) can starve the entire application. Always pass your own ExecutorService for I/O-bound work.
  • **Using thenApply when you need thenCompose.** If your transformation *itself* returns a CompletableFuture, use thenCompose (flatMap). Using thenApply gives you a nested CompletableFuture<CompletableFuture<T>> โ€” not what you want!
  • Swallowing exceptions silently. If you don't add .exceptionally() or .handle(), exceptions inside the chain are stored in the future but never logged. The program appears to "silently do nothing." Always add error handling at the end of the chain.
  • **Calling .get() instead of .join().** .get() throws checked ExecutionException, forcing ugly try-catch blocks. .join() throws unchecked CompletionException, which is cleaner. Both block the calling thread, so use sparingly.
  • Creating too many async stages. Each stage may involve thread handoffs and object allocations. For trivial transformations (x -> x.toString()), consider doing them synchronously. Over-chaining adds overhead without benefit.
  • **Forgetting that allOf returns CompletableFuture<Void>.** It just signals completion โ€” it doesn't give you the results. You need to call .join() on each individual future to collect results.

Interview Tip

CompletableFuture questions often appear as: *"How would you call 3 microservices in parallel and combine the results?"* Show this pattern:

java
1CompletableFuture<A> fA = CompletableFuture.supplyAsync(() -> callServiceA(), ioPool);
2CompletableFuture<B> fB = CompletableFuture.supplyAsync(() -> callServiceB(), ioPool);
3CompletableFuture<C> fC = CompletableFuture.supplyAsync(() -> callServiceC(), ioPool);
4
5CompletableFuture.allOf(fA, fB, fC)
6    .thenRun(() -> {
7        A a = fA.join();
8        B b = fB.join();
9        C c = fC.join();
10        return merge(a, b, c);
11    });

Also explain the difference between thenApply (map) vs thenCompose (flatMap), and why you should always use a custom executor for I/O. This shows deep understanding beyond just knowing the API.


Quick Quiz

  1. What's the difference between thenApply and thenCompose? When would using thenApply give you a CompletableFuture<CompletableFuture<T>>?
  1. You have two CompletableFutures: one calls a fast API (100ms), the other calls a slow API (5 seconds). You only need the first result that arrives. Which method do you use?
  1. Why is it dangerous to use CompletableFuture.supplyAsync(() -> blockingIOCall()) without a second argument (executor)?

Summary โ€” Key Takeaways

  • **CompletableFuture** lets you chain, combine, and compose async operations without blocking threads, using methods like thenApply, thenCompose, thenCombine, and allOf.
  • Always provide a custom executor for I/O-bound tasks โ€” never rely on the default ForkJoinPool.commonPool() for blocking work.
  • Use **thenCompose** (not thenApply) when your transformation returns another CompletableFuture โ€” it's the flatMap equivalent.
  • Always add error handling (.exceptionally() or .handle()) to avoid silently swallowed exceptions.