Skip to content

CompletableFuture — Asynchronous Programming with supplyAsync, thenCompose, and Exception Handling

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about CompletableFuture. We cover key concepts, practical examples, and best practices to help you master this topic.

CompletableFuture in Java enables asynchronous, non-blocking programming with a composable future API for tasks that may complete in a different thread. Before CompletableFuture (Java 8), Java had Future — which required blocking get() calls and provided no composition. CompletableFuture lets you chain async operations declaratively.

What You'll Learn

  • Creating futures: supplyAsync, runAsync, completedFuture
  • Chaining: thenApply, thenCompose, thenAccept, thenRun
  • Combining: thenCombine, allOf, anyOf
  • Exception Handling: exceptionally, handle, whenComplete

Why It Matters

Modern applications are I/O-bound — waiting for databases, APIs, and file systems. Asynchronous programming improves throughput by not blocking threads during I/O. CompleteableFuture is the standard tool for async in Java.

Real-World Use

Microservices call multiple downstream services in parallel. Web servers handle thousands of concurrent requests without creating a thread per request. Batch processing pipelines chain stages.


Creating CompletableFuture

completedFuture

CompletableFuture<String> future = CompletableFuture.completedFuture("Hello");
String result = future.get(); // "Hello" (immediately available)

supplyAsync

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    // Runs on ForkJoinPool.commonPool()
    return "Task result";
});

runAsync

CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
    System.out.println("Running async task");
});

Custom Executor

ExecutorService executor = Executors.newFixedThreadPool(10);
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    return "Result";
}, executor);

Chaining Operations

thenApply (transform)

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> "Hello")
    .thenApply(s -> s + " World")
    .thenApply(String::toUpperCase);

future.get(); // "HELLO WORLD"

thenCompose (flatMap)

Used when the transformation itself returns a CompletableFuture:

public CompletableFuture<String> getUserName(Long id) {
    return CompletableFuture.supplyAsync(() -> "Alice");
}

CompletableFuture<String> result = CompletableFuture
    .supplyAsync(() -> 42L)
    .thenCompose(this::getUserName);

Without thenCompose, you would get CompletableFuture<CompletableFuture<String>>.

thenAccept (consume)

CompletableFuture
    .supplyAsync(() -> "Hello")
    .thenAccept(result -> System.out.println("Result: " + result));

thenRun (run after completion)

CompletableFuture
    .supplyAsync(() -> "Data")
    .thenRun(() -> System.out.println("Task completed"));

Combining Independent Futures

thenCombine

CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "World");

CompletableFuture<String> combined = f1.thenCombine(f2, (a, b) -> a + " " + b);
combined.get(); // "Hello World"

allOf

Wait for all futures to complete:

CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "A");
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "B");
CompletableFuture<String> f3 = CompletableFuture.supplyAsync(() -> "C");

CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
all.get(); // blocks until all complete

// Collect results
List<String> results = Stream.of(f1, f2, f3)
    .map(CompletableFuture::join)
    .toList();

anyOf

CompletableFuture<Object> first = CompletableFuture.anyOf(f1, f2, f3);
Object result = first.get(); // returns the first one to complete

Exception Handling

exceptionally

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> {
        if (Math.random() > 0.5) throw new RuntimeException("Failed");
        return "Success";
    })
    .exceptionally(ex -> "Fallback: " + ex.getMessage());

future.get(); // "Success" or "Fallback: Failed"

handle

Handle both success and failure:

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> {
        if (Math.random() > 0.5) throw new RuntimeException("Failed");
        return "Success";
    })
    .handle((result, ex) -> {
        if (ex != null) return "Recovered: " + ex.getMessage();
        return result;
    });

whenComplete

Perform side effect on completion (does not modify result):

future.whenComplete((result, ex) -> {
    if (ex == null) {
        System.out.println("Success: " + result);
    } else {
        System.err.println("Failed: " + ex.getMessage());
    }
});

CompleteableFuture vs Future

Feature Future CompletableFuture
Blocking get Yes Yes (but discouraged)
Callback/chain No Yes
Combine multiple No Yes (allOf, anyOf)
Exception handling Manual Built-in
Manual completion No Yes (complete, completeExceptionally)

Common Mistakes

  1. Calling get() in the main thread. This blocks the calling thread, defeating the purpose of async. Prefer thenAccept or join in non-blocking contexts.
  2. Ignoring the ForkJoinPool default. supplyAsync uses the common ForkJoinPool. For I/O operations, provide a custom executor with more threads.
  3. Not handling exceptions. An unhandled exception in a CompletableFuture is silently swallowed unless you attach exceptionally or handle.
  4. Blocking inside a stage. Do not call future.get() inside a thenApply — use thenCompose instead.
  5. Forgetting to shut down custom executors. Always call executorService.shutdown() after use.

Practice Questions

1. What is the difference between thenApply and thenCompose?
thenApply transforms the result synchronously. thenCompose chains another CompletableFuture — used when the transformation itself returns a future (avoids nesting).

2. How do you wait for multiple CompletableFutures?
Use CompletableFuture.allOf(f1, f2, f3) which returns CompletableFuture<Void> that completes when all complete.

3. What is the difference between exceptionally and handle?
exceptionally only handles exceptions (not success). handle receives both the result and exception, allowing you to transform either.

4. Why should you use a custom executor for I/O operations?
The common ForkJoinPool has limited parallelism (usually #CPU cores). I/O operations should not consume CPU-bound threads. A custom executor with more threads prevents starvation.

5. What does completeExceptionally do?
It manually completes the future with an exception, causing dependent stages to trigger their exception handlers.

Challenge Question:
Write a method CompletableFuture<List<String>> fetchUserData(List<Long> userIds) that fetches data for each user concurrently (simulate with Thread.sleep). Limit concurrency to 3 at a time using a custom executor. Collect all results when all complete. Handle partial failures by returning a default value for failed users.

FAQ

What is the difference between `get()` and `join()`?

Both wait for completion. get() throws checked exceptions (InterruptedException, ExecutionException). join() throws unchecked exceptions (CompletionException). join() is preferred in stream pipelines.

Can CompletableFuture be cancelled?

Yes. cancel(true) attempts to cancel. If the future is already complete, cancellation has no effect. The mayInterruptIfRunning parameter may not actually interrupt the thread.

What is the `ForkJoinPool.commonPool()`?

The default pool for parallel operations. Its size is Runtime.getRuntime().availableProcessors() - 1. It is shared across all CompletableFutures that do not specify a custom executor.

How do I set a timeout for a CompletableFuture?

Use future.orTimeout(timeout, unit) (Java 9+) which completes the future exceptionally with a TimeoutException if not completed within the duration.

Is CompletableFuture suitable for CPU-bound tasks?

It can be, but CPU-bound tasks on the common pool compete with other parallel operations. For CPU-bound work, use parallel streams or a dedicated executor sized to available processors.

Mini Project

Write a program CompletableFutureDemo.java that:

  1. Simulates fetching user data from three remote services (user profile, orders, recommendations) each taking 1-2 seconds
  2. Fetches all three in parallel using supplyAsync with a custom executor
  3. Combines results with thenCombine or allOf
  4. Transforms the combined result into a user dashboard string
  5. Adds exception handling: if any service fails, return a partial result
  6. Times the parallel execution vs sequential
  7. Demonstrates thenCompose by chaining "get user -> get orders -> calculate total"
  8. Uses completeOnTimeout (Java 9+) to provide fallback values

What's Next

CompletableFuture and lambdas depend on functional interfaces. Lesson 38 explores the core functional interfaces in java.util.function — Predicate, Function, Consumer, Supplier — and how to create custom functional interfaces for domain-specific operations.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro