CompletableFuture

CompletableFuture — Java's async programming model for composing non-blocking operations and handling results and errors.

Last reviewed: January 2025

·Java 8Java 11Java 17Java 21
✅
Quick Understanding: CompletableFuture is Java's promise/future for async programming. You start an async operation, attach callbacks to run when it completes, and compose multiple async operations into pipelines. Unlike Future.get() which blocks, CompletableFuture is non-blocking — callbacks run when the result is ready. It handles both results and exceptions in a unified API.

🔍 What Is It?

CompletableFuture API:

Creating:

  • →supplyAsync(Supplier) — async, returns value
  • →runAsync(Runnable) — async, no return value
  • →completedFuture(value) — already completed with value

Transforming result:

  • →thenApply(Function) — transform result (like Stream.map)
  • →thenCompose(Function<T, CompletableFuture<U>>) — chain async operations (like flatMap)
  • →thenAccept(Consumer) — consume result, no return
  • →thenRun(Runnable) — run after completion, ignores result

Combining:

  • →thenCombine(other, BiFunction) — combine two futures when both complete
  • →allOf(futures...) — wait for all to complete
  • →anyOf(futures...) — complete when first finishes

Exception handling:

  • →exceptionally(Function<Throwable, T>) — recover from exception
  • →handle(BiFunction<T, Throwable, U>) — handle both result and exception
  • →whenComplete(BiConsumer) — side effect on completion (success or failure)

Async variants:

  • →thenApplyAsync, thenComposeAsync etc. — run callback in executor (not inline)

🎯 Why Do We Need It?

CompletableFuture solves the "callback hell" of raw threads and the blocking of Future.get():

// Old Future: blocking
Future<User> userFuture = executor.submit(() -> fetchUser(id));
Future<Orders> ordersFuture = executor.submit(() -> fetchOrders(id));
User user = userFuture.get();    // blocks!
Orders orders = ordersFuture.get();  // blocks!

// CompletableFuture: non-blocking composition
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture<Orders> ordersFuture = CompletableFuture.supplyAsync(() -> fetchOrders(id));
CompletableFuture.allOf(userFuture, ordersFuture)
    .thenRun(() -> {
        User user = userFuture.join();
        Orders orders = ordersFuture.join();
        buildResponse(user, orders);
    });

🏗️ What Happens in Real Projects?

Common in enterprise microservices:

  • →Parallel API calls to multiple services
  • →Async database operations
  • →Spring @Async methods returning CompletableFuture
  • →Non-blocking HTTP clients (WebClient)

⚙️ How It Works

Execution model:

By default, CompletableFuture.supplyAsync() uses ForkJoinPool.commonPool(). For production, always provide your own executor to control thread count and naming.

thenApply vs thenApplyAsync:

  • →thenApply: callback runs in the same thread that completed the future (or the calling thread if already complete)
  • →thenApplyAsync: callback runs in the executor (ForkJoinPool or provided executor)
  • →For I/O operations in callbacks, use Async variants to avoid blocking the completing thread

💻 Example

CompletableFuture in Enterprise Code — Async patterns for microservice communication and parallel operations.

java
// Parallel API calls — much faster than sequential
public CompletableFuture<OrderSummary> getOrderSummary(Long orderId) {
    ExecutorService executor = Executors.newFixedThreadPool(10);
    
    // Start all three calls simultaneously
    CompletableFuture<Order> orderFuture = 
        CompletableFuture.supplyAsync(() -> orderService.getOrder(orderId), executor);
    
    CompletableFuture<Customer> customerFuture = orderFuture
        .thenComposeAsync(order -> 
            CompletableFuture.supplyAsync(
                () -> customerService.getCustomer(order.getCustomerId()), executor
            ), executor);
    
    CompletableFuture<List<Product>> productsFuture = orderFuture
        .thenComposeAsync(order ->
            CompletableFuture.supplyAsync(
                () -> productService.getProducts(order.getProductIds()), executor
            ), executor);
    
    // Combine when all complete
    return orderFuture.thenCombineAsync(
        customerFuture.thenCombineAsync(
            productsFuture,
            (customer, products) -> new CustomerProducts(customer, products),
            executor
        ),
        (order, cp) -> new OrderSummary(order, cp.customer(), cp.products()),
        executor
    );
}

// Exception handling
public CompletableFuture<String> fetchWithFallback(String userId) {
    return CompletableFuture.supplyAsync(() -> primaryService.fetch(userId))
        .exceptionally(ex -> {
            log.warn("Primary service failed: {}, using fallback", ex.getMessage());
            return fallbackService.fetch(userId);  // fallback value
        });
}

// handle — process both success and failure
public CompletableFuture<ApiResponse> callApi(String url) {
    return CompletableFuture.supplyAsync(() -> httpClient.get(url))
        .handle((result, ex) -> {
            if (ex != null) {
                return ApiResponse.error(ex.getMessage());
            }
            return ApiResponse.success(result);
        });
}

🤔 Common Confusions

❓ What is the difference between thenApply and thenCompose?

thenApply is like Stream.map() — it transforms the result to another value: CompletableFuture<T>.thenApply(T -> U) returns CompletableFuture<U>. thenCompose is like Stream.flatMap() — it chains to another async operation: CompletableFuture<T>.thenCompose(T -> CompletableFuture<U>) returns CompletableFuture<U> (not CompletableFuture<CompletableFuture<U>>). Use thenCompose when the transformation itself is async.

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

Both block until the future completes. get() throws checked exceptions (InterruptedException, ExecutionException). join() throws unchecked exceptions (CompletionException). In lambda expressions (which cannot throw checked exceptions), use join(). In regular code where you want to handle InterruptedException, use get().

🚨 Common Production Issues

Using ForkJoinPool.commonPool() for I/O operations — the common pool has limited threads. I/O operations block these threads, starving parallel streams. Always provide a custom executor for I/O-bound CompletableFutures.

Exception swallowing — if you do not attach an exceptionally() or handle() handler, exceptions are silently swallowed. Always handle exceptions in CompletableFuture pipelines.

Blocking in thenApply — thenApply runs in the completing thread. If you do I/O in thenApply, you block that thread. Use thenApplyAsync with an executor for I/O operations.

✅ Best Practices

Always provide a custom executor for production CompletableFutures.

Always handle exceptions with exceptionally() or handle().

Use thenApplyAsync for I/O operations in callbacks.

Use orTimeout() (Java 9+) to prevent indefinitely hanging futures.

Use CompletableFuture.allOf() for parallel execution, not sequential chaining.

Return CompletableFuture from @Async methods for proper async handling.

🏛️ Architect's Note

Senior Architect's Perspective

CompletableFuture is the right tool for orchestrating multiple async operations in a service. However, for reactive streams with backpressure (handling millions of events), use Project Reactor (Spring WebFlux) or RxJava. With Java 21 virtual threads, much of the complexity of CompletableFuture can be avoided — simple blocking code with virtual threads achieves similar concurrency with less complexity.

💬 Frequently Asked Questions

When should I use CompletableFuture vs Spring @Async?

@Async is simpler — annotate a method, Spring runs it in a thread pool. Use it for simple fire-and-forget or single async operations. CompletableFuture gives you full control over async pipelines, composition, and error handling. Use it when you need to combine multiple async operations, handle timeouts, or implement complex async workflows. @Async methods can return CompletableFuture for the best of both worlds.

🎯 Key Takeaways

1CompletableFuture enables non-blocking async composition
2thenApply: transform result; thenCompose: chain async operation; thenAccept: consume
3allOf: wait for all; anyOf: wait for first
4Always handle exceptions with exceptionally() or handle()
5Always provide a custom executor for production use
6Use orTimeout() (Java 9+) to prevent hanging futures

🎯 Practice this topic in interviews

↗ Where this topic fits

Sources

Sources / References

Use these Core Java references to verify the concept in official docs and continue into the most relevant SplashRide pages.