Skip to main content
    Courses/Java/CompletableFuture

    Lesson 28 • Advanced

    CompletableFuture & Async Programming

    By the end of this lesson you'll start background work, chain and combine async steps into a clean pipeline, handle failures without crashing, and run tasks in parallel on a pool you control.

    What You'll Learn in This Lesson

    • Start background work with supplyAsync() and runAsync()
    • Transform and consume results with thenApply, thenAccept, thenRun
    • Chain dependent calls with thenCompose, merge parallel ones with thenCombine
    • Recover from failures using exceptionally() and handle()
    • Coordinate many tasks with allOf() and anyOf()
    • Run blocking work safely on a custom executor

    Before You Start

    You'll get the most from this if you've done Multithreading, Lambda Expressions, and the Streams API. A CompletableFuture is Java's version of a Promise — it represents a result that isn't ready yet, and it lets you describe what to do when it is, using the same functional, chainable style you learned with streams.

    1️⃣ From Blocking Calls to Async Pipelines

    A normal method call blocks: your thread stops and waits for the answer before moving on. With CompletableFuture you instead say "start this work, and here's the recipe for what to do when it finishes" — then your thread is free to keep going. You build that recipe by chaining methods, and the value flows down the chain.

    💡 Analogy: a food delivery app. supplyAsync() is placing an order — it starts cooking in the background and hands you a tracking number (the future). thenApply() is "when it arrives, add a tip to the receipt" (transform the result). thenCompose() is "when it arrives, order dessert from the same kitchen" (a second async step that depends on the first). thenCombine() is ordering food and drinks at the same time and combining them when both land. exceptionally() is your backup plan — "if the order fails, grab a pizza instead."

    The key shift: you don't ask "is it done yet?" over and over. You hand the future a callback and let it run that callback for you the moment the value is ready.

    2️⃣ Start, Transform, Consume

    There are two ways to start: supplyAsync runs a task that returns a value (a Supplier), and runAsync runs a task that returns nothing (a Runnable). Once you have a future, three "then" methods cover most needs:

    • thenApply(value → newValue)transform the result, like map. Returns a future of the new value.
    • thenAccept(value → void)consume the result (e.g. print it). Returns CompletableFuture<Void>.
    • thenRun(() → void) — run an action that ignores the value, for cleanup or logging.

    In the worked example below, the background lines can print before or after main's own lines because they run on another thread — the ordering of the indented lines is not guaranteed. join() at the end waits for the result.

    Worked example: supplyAsync, runAsync, thenApply, thenAccept
    import java.util.concurrent.CompletableFuture;
    
    public class Main {
        public static void main(String[] args) throws Exception {
            System.out.println("=== Starting async work ===");
    
            // supplyAsync: run a task in the background that RETURNS a value.
            // The lambda runs on another thread; you get back a CompletableFuture.
            CompletableFuture<String> nameFuture =
                CompletableFuture.supplyAsync(() -> {
                    System.out.println("  fetching user...");   // runs on a pool thread
                    return "Alice";                              // this becomes the result
                });
    
            // runAsync: run a task that returns NOTHING (a Runnable, not a Supplier).
            CompletableFuture<Void> logFuture =
                CompletableFuture.runAsync(() -> System.out.println("  writing log entry"));
    
            // thenApply: TRANSFORM the result (like map). Takes a value, returns a value.
            CompletableFuture<Integer> lengthFuture =
                nameFuture.thenApply(name -> name.length());   // "Alice" -> 5
    
            // thenAccept: CONSUME the result (like forEach). Takes a value, returns nothing.
            lengthFuture.thenAccept(len -> System.out.println("  name length = " + len));
    
            // join() waits for the pipeline and returns the result (no checked exception).
            logFuture.join();
            System.out.println("Final name: " + nameFuture.join());
            System.out.println("Done.");
        }
    }
    Output
    === Starting async work ===
      writing log entry
      fetching user...
      name length = 5
    Final name: Alice
    Done.
    Real Java using java.util.concurrent.CompletableFuture. Run it free at onecompiler.com/java. Because the lambdas run on a thread pool, the two indented background lines may swap order between runs — the output shown is one valid ordering.

    🎯 Your Turn #1 — transform then consume

    Fill in the three blanks: return a value from supplyAsync, transform it with the map-style method, then consume it with the print-style method. Check your output against the ✅ Expected output comment.

    Your Turn: complete the pipeline
    import java.util.concurrent.CompletableFuture;
    
    public class Main {
        public static void main(String[] args) {
            // 🎯 YOUR TURN — fill in the blanks marked with ___
    
            // 1) Start an async task that returns the price 100 (an Integer)
            CompletableFuture<Integer> priceFuture =
                CompletableFuture.supplyAsync(() -> ___);   // 👉 return the number 100
    
            // 2) Transform the price by adding 20 tax, using thenApply
            CompletableFuture<Integer> withTax =
                priceFuture.___(price -> price + 20);        // 👉 the "map" method
    
            // 3) Consume (print) the final value using thenAccept, then wait with join()
            withTax.___(total -> System.out.println("Total: " + total))  // 👉 the "consume" method
                   .join();
    
            // ✅ Expected output:
            // Total: 120
        }
    }
    Replace each ___, then run it at onecompiler.com/java. Hint: the methods you need are thenApply and thenAccept, and the missing number is 100.

    3️⃣ Compose vs Combine, and Handling Failure

    thenCompose — sequential

    Second step depends on the first and itself returns a future. Like flatMap — fetch user, then fetch that user's orders.

    thenCombine — parallel

    Two independent futures run at once and merge when both finish — fetch price AND discount, then compute total.

    For failure, you have two tools. exceptionally(ex → fallback) runs only on failure and returns a replacement value. handle((result, error) → ...) runs on both success and failure — you get both arguments and exactly one is null, which is perfect for logging or branching in one spot.

    Worked example: thenCompose, thenCombine, exceptionally, handle
    import java.util.List;
    import java.util.concurrent.CompletableFuture;
    
    public class Main {
        record User(int id, String name) {}
    
        // Each of these returns a CompletableFuture (an async call).
        static CompletableFuture<User> getUser(int id) {
            return CompletableFuture.supplyAsync(() -> new User(id, "User-" + id));
        }
        static CompletableFuture<List<String>> getOrders(User user) {
            return CompletableFuture.supplyAsync(() -> List.of("Order-A", "Order-B"));
        }
    
        public static void main(String[] args) {
            // thenCompose: SEQUENTIAL async. The second call needs the first's result.
            // Use thenCompose (not thenApply) when the function returns a future.
            String chained = getUser(42)
                .thenCompose(user -> getOrders(user))                 // User -> future of orders
                .thenApply(orders -> "orders=" + orders.size())       // flatten, then map
                .join();
            System.out.println("thenCompose -> " + chained);
    
            // thenCombine: PARALLEL async. Two independent futures, merged when BOTH finish.
            CompletableFuture<Integer> price    = CompletableFuture.supplyAsync(() -> 100);
            CompletableFuture<Double>  discount = CompletableFuture.supplyAsync(() -> 0.20);
            double total = price.thenCombine(discount, (p, d) -> p * (1 - d)).join();
            System.out.println("thenCombine -> total=" + total);     // 100 * 0.8 = 80.0
    
            // exceptionally: only runs ON FAILURE, supplying a fallback value.
            String recovered = CompletableFuture
                .<String>supplyAsync(() -> { throw new RuntimeException("API down"); })
                .exceptionally(ex -> "fallback (" + ex.getCause().getMessage() + ")")
                .join();
            System.out.println("exceptionally -> " + recovered);
    
            // handle: runs on SUCCESS *and* FAILURE. You get (result, error) — one is null.
            String handled = CompletableFuture
                .supplyAsync(() -> "ok-result")
                .handle((result, error) -> error == null ? "got " + result : "error: " + error)
                .join();
            System.out.println("handle -> " + handled);
        }
    }
    Output
    thenCompose -> orders=2
    thenCombine -> total=80.0
    exceptionally -> fallback (API down)
    handle -> got ok-result
    Real Java. Each line is followed by join() so the output prints in order — run it at onecompiler.com/java.

    🎯 Your Turn #2 — chain async with a safety net

    Here lookupCity returns a CompletableFuture, so you must chain it with the flatMap-style method rather than thenApply — otherwise you'd end up with a nested future. Then add a failure-only fallback so a broken lookup returns "Unknown" instead of throwing.

    Your Turn: thenCompose + exceptionally
    import java.util.concurrent.CompletableFuture;
    
    public class Main {
        static CompletableFuture<String> lookupCity(int id) {
            return CompletableFuture.supplyAsync(() -> "London");
        }
    
        public static void main(String[] args) {
            // 🎯 YOUR TURN — chain an async call and add a safety net
    
            String city = CompletableFuture
                .supplyAsync(() -> 42)                       // a user id arrives
                // 1) lookupCity returns a CompletableFuture, so chain with the
                //    flatMap-style method (NOT thenApply) to avoid a nested future:
                .___(id -> lookupCity(id))                   // 👉 thenCompose
    
                // 2) If anything failed, fall back to "Unknown" using the
                //    failure-only recovery method:
                .___(ex -> "Unknown")                        // 👉 exceptionally
                .join();
    
            System.out.println("City: " + city);
    
            // ✅ Expected output:
            // City: London
        }
    }
    The two blanks are thenCompose and exceptionally. Run it at onecompiler.com/java and check it prints City: London.

    4️⃣ Custom Executors, allOf and anyOf

    By default, supplyAsync runs on the shared ForkJoinPool.commonPool(). That pool is tuned for short, CPU-bound bursts. If you run blocking I/O on it (network calls, database queries), those threads sit idle waiting and starve every other task in the JVM. The fix is to pass your own ExecutorService as the second argument to supplyAsync / runAsync.

    To coordinate several futures, use allOf(...) (completes when all finish) or anyOf(...) (completes when the first finishes — great for racing mirrors and taking the fastest). Note that allOf returns CompletableFuture<Void>, so after it completes you re-join() each future to read its value.

    Worked example: custom executor, allOf, anyOf
    import java.util.List;
    import java.util.concurrent.*;
    
    public class Main {
        public static void main(String[] args) throws Exception {
            // A custom executor: a fixed pool you control. Pass it as the 2nd arg so
            // blocking I/O does NOT run on the shared default ForkJoinPool.commonPool().
            ExecutorService pool = Executors.newFixedThreadPool(4);
    
            // allOf: wait for EVERY future to finish. It returns Void, so re-join each
            // one to read the individual results once allOf has completed.
            CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> "DB ready", pool);
            CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> "Cache ready", pool);
            CompletableFuture<String> c = CompletableFuture.supplyAsync(() -> "Config ready", pool);
    
            CompletableFuture.allOf(a, b, c).join();                 // block until all 3 done
            List<String> all = List.of(a.join(), b.join(), c.join());
            System.out.println("allOf  -> " + all);
    
            // anyOf: complete as soon as the FIRST future finishes (racing / fastest wins).
            CompletableFuture<Object> fastest = CompletableFuture.anyOf(
                CompletableFuture.supplyAsync(() -> "mirror-1", pool),
                CompletableFuture.supplyAsync(() -> "mirror-2", pool)
            );
            System.out.println("anyOf  -> first responder: " + fastest.join());
    
            // ALWAYS shut a custom pool down, or the JVM will not exit (non-daemon threads).
            pool.shutdown();
            System.out.println("pool shut down");
        }
    }
    Output
    allOf  -> [DB ready, Cache ready, Config ready]
    anyOf  -> first responder: mirror-1
    pool shut down
    Real Java. The anyOf winner depends on scheduling, so mirror-2 could appear instead — run it at onecompiler.com/java.

    🎯 Mini-Challenge — parallel dashboard

    Now write it yourself from the outline. Fire two async tasks on a custom executor, merge them with thenCombine, add an exceptionally fallback, print the result, and shut the pool down. Only a comment outline is provided — fill in the logic.

    Mini-Challenge: combine two async tasks safely
    import java.util.concurrent.*;
    
    public class Main {
        static CompletableFuture<Integer> fetchTotal(int userId) {
            return CompletableFuture.supplyAsync(() -> 250);   // pretend this calls a service
        }
    
        public static void main(String[] args) {
            // 🎯 MINI-CHALLENGE: parallel dashboard with a safety net
            //
            // 1. Start TWO async tasks with supplyAsync on a custom executor
            //    (Executors.newFixedThreadPool(2)):
            //       - one returning the user's name  ("Alice")
            //       - one returning fetchTotal(1)     (an Integer spend total)
            // 2. Combine them with thenCombine into a string:  name + " spent $" + total
            // 3. Add .exceptionally(ex -> "dashboard unavailable") as a fallback
            // 4. Print the joined result, then shut the executor down
            //
            // ✅ Expected output:
            // Alice spent $250
    
            // your code here
        }
    }
    Follow the numbered steps in the comments. Target output is Alice spent $250 — try it at onecompiler.com/java.

    Common Errors (and the fix)

    ❌ Blocking immediately with get()/join()

    String name = CompletableFuture.supplyAsync(() -> fetchName()).get(); // blocks the thread

    You've made it async then immediately waited — defeating the point. Fix: keep chaining with thenApply/thenAccept; if you truly must block, bound it with get(5, TimeUnit.SECONDS) or orTimeout(5, TimeUnit.SECONDS) so a stuck task can't hang forever.

    ❌ Swallowing exceptions silently

    future.thenApply(x -> risky(x)); // if risky() throws, nobody ever sees it

    A failed stage just leaves the future "completed exceptionally" — no stack trace prints on its own. Fix: always end a chain you don't join() with .exceptionally(ex -> ...) or .handle(...), and remember those return a new future, so chain from their result.

    ❌ thenApply where you needed thenCompose

    // getOrders returns CompletableFuture<List<...>>
    CompletableFuture<CompletableFuture<List<String>>> nested =
        getUser(1).thenApply(u -> getOrders(u)); // nested future!

    Fix: when the callback returns a future, use thenCompose instead of thenApply so the result is flattened to a single CompletableFuture<List<String>>.

    ❌ Blocking I/O on the default ForkJoinPool

    CompletableFuture.supplyAsync(() -> httpGet(url)); // runs on shared commonPool

    Blocking calls on the common pool tie up its few threads and starve unrelated tasks across the whole JVM. Fix: pass a dedicated pool — supplyAsync(() -> httpGet(url), ioExecutor) — and shutdown() it when done.

    Pro Tips

    💡 handle() over exceptionally() when you want one place that logs and returns regardless of success or failure.

    💡 orTimeout() (Java 9+): future.orTimeout(5, TimeUnit.SECONDS) fails a stuck future instead of letting it hang.

    💡 allOf returns Void — gather values with futures.stream().map(CompletableFuture::join).toList() after it completes.

    💡 The async variants (thenApplyAsync, etc.) run the callback on a different thread/pool — useful when one step is far heavier than the rest.

    📋 Quick Reference

    MethodCategoryPurpose
    supplyAsync(s)CreateRun a task that returns a value
    runAsync(r)CreateRun a task that returns nothing
    thenApply(f)TransformMap the result (value → value)
    thenAccept(c)ConsumeUse the result, return nothing
    thenCompose(f)ChainSequential async (flatMap)
    thenCombine(o, f)CombineMerge two parallel futures
    exceptionally(f)ErrorFallback value on failure only
    handle(f)ErrorRun on success and failure
    allOf(...)CoordinateWait for every future
    anyOf(...)CoordinateComplete on the first future
    orTimeout(t, u)SafetyFail if it takes too long

    Frequently Asked Questions

    What is the difference between thenApply and thenCompose?

    Use thenApply when your function returns a plain value — it works like map and gives you CompletableFuture<T>. Use thenCompose when your function itself returns a CompletableFuture — it works like flatMap and flattens the result, so you get CompletableFuture<T> instead of a nested CompletableFuture<CompletableFuture<T>>. Rule of thumb: if the callback returns a future, reach for thenCompose.

    When should I use exceptionally versus handle?

    exceptionally only runs when the stage failed, and it returns a single fallback value of the same type. handle always runs — on both success and failure — and receives two arguments (result, throwable) where exactly one is non-null. Use exceptionally for a simple recovery value, and handle when you want to log, transform, or branch on both outcomes in one place.

    Why is calling get() or join() considered a mistake?

    Both block the current thread until the future completes, which throws away the whole point of async code. get() can block forever and forces you to handle checked exceptions; join() blocks too but wraps errors in an unchecked CompletionException. Prefer chaining with thenApply/thenAccept/thenCompose so work stays non-blocking, and if you must block, use get(timeout, unit) or orTimeout so a stuck task cannot hang your program.

    Do I need a custom executor or is the default fine?

    supplyAsync and runAsync default to the shared ForkJoinPool.commonPool(), which is sized for short CPU-bound work. If you run blocking I/O (HTTP calls, JDBC, file reads) on it, those threads sit idle waiting and starve every other task in the JVM that shares that pool. For blocking work, pass your own ExecutorService (e.g. Executors.newFixedThreadPool(n)) as the second argument, and remember to shut it down.

    How do I read the results after allOf completes?

    allOf returns CompletableFuture<Void>, so it tells you everything finished but carries no values. After allOf(...).join(), call join() on each individual future to read its result, or collect them with futures.stream().map(CompletableFuture::join).toList(). Because each future has already completed, those join() calls return immediately without blocking.

    🎉 Lesson Complete!

    You can now start background work with supplyAsync/runAsync, build pipelines with thenApply/thenAccept/thenCompose/thenCombine, recover from failures with exceptionally/handle, coordinate tasks with allOf/anyOf, and keep blocking work off the common pool with a custom executor.

    Next up: Memory Management & JVM Garbage Collection — what really happens to your objects and threads under the hood.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service