Skip to content
Codeloom
Java

Java CompletableFuture Patterns for Async Code

Master CompletableFuture composition patterns including chaining, combining, error handling, timeouts, and async pipeline design in Java.

·6 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How to chain dependent async operations with thenCompose
  • How to run independent operations in parallel with allOf and anyOf
  • Error handling and recovery strategies
  • Timeout patterns and executor management

Prerequisites

None — this post is self-contained.

CompletableFuture is Java’s primary tool for composing asynchronous operations. Unlike the older Future interface, it supports chaining, combining, and error recovery without blocking. This article covers practical patterns you will use when building async pipelines in production Java applications.

The Basics: thenApply vs thenCompose

The two most important composition methods are thenApply (synchronous transformation) and thenCompose (asynchronous chaining, analogous to flatMap).

import java.util.concurrent.CompletableFuture;

public class BasicComposition {
    // Simulated async service calls
    static CompletableFuture<String> fetchUser(int id) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(100);
            return "User-" + id;
        });
    }

    static CompletableFuture<String> fetchProfile(String username) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(100);
            return "Profile for " + username;
        });
    }

    public static void main(String[] args) {
        // thenApply: transform the result synchronously
        CompletableFuture<String> upper = fetchUser(1)
            .thenApply(String::toUpperCase);

        // thenCompose: chain another async operation (flatMap)
        CompletableFuture<String> profile = fetchUser(1)
            .thenCompose(user -> fetchProfile(user));

        System.out.println(upper.join());   // USER-1
        System.out.println(profile.join()); // Profile for User-1
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Use thenApply when the transformation is fast and synchronous. Use thenCompose when the next step is itself asynchronous and returns a CompletableFuture.

Parallel Independent Operations

When you need results from multiple independent services, launch them concurrently and combine the results.

allOf: Wait for Everything

import java.util.concurrent.CompletableFuture;
import java.util.List;

record Dashboard(String user, String orders, String recommendations) {}

public class ParallelFetch {
    static CompletableFuture<String> fetchUserData() {
        return CompletableFuture.supplyAsync(() -> {
            sleep(200);
            return "Alice";
        });
    }

    static CompletableFuture<String> fetchOrders() {
        return CompletableFuture.supplyAsync(() -> {
            sleep(300);
            return "5 orders";
        });
    }

    static CompletableFuture<String> fetchRecommendations() {
        return CompletableFuture.supplyAsync(() -> {
            sleep(150);
            return "3 recommendations";
        });
    }

    static CompletableFuture<Dashboard> loadDashboard() {
        var userFuture = fetchUserData();
        var ordersFuture = fetchOrders();
        var recosFuture = fetchRecommendations();

        return CompletableFuture.allOf(userFuture, ordersFuture, recosFuture)
            .thenApply(v -> new Dashboard(
                userFuture.join(),
                ordersFuture.join(),
                recosFuture.join()
            ));
    }

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        Dashboard dash = loadDashboard().join();
        long elapsed = System.currentTimeMillis() - start;

        System.out.printf("Dashboard: %s | %s | %s (in %d ms)%n",
            dash.user(), dash.orders(), dash.recommendations(), elapsed);
        // Takes ~300ms (slowest call), not 650ms (sum of all)
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

anyOf: First Response Wins

Use anyOf when you want the first result from redundant sources, for example querying multiple replicas:

static CompletableFuture<String> queryReplica(String name, long latency) {
    return CompletableFuture.supplyAsync(() -> {
        sleep(latency);
        return "Result from " + name;
    });
}

public static void main(String[] args) {
    CompletableFuture<Object> fastest = CompletableFuture.anyOf(
        queryReplica("US-East", 100),
        queryReplica("EU-West", 200),
        queryReplica("AP-South", 50)
    );

    System.out.println(fastest.join()); // Result from AP-South
}

Collecting Futures from a List

A common pattern is launching async work for a collection of items and waiting for all results:

static CompletableFuture<String> processItem(int item) {
    return CompletableFuture.supplyAsync(() -> {
        sleep(50);
        return "processed-" + item;
    });
}

static <T> CompletableFuture<List<T>> allOfList(List<CompletableFuture<T>> futures) {
    return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
        .thenApply(v -> futures.stream()
            .map(CompletableFuture::join)
            .toList());
}

public static void main(String[] args) {
    List<CompletableFuture<String>> futures = List.of(1, 2, 3, 4, 5)
        .stream()
        .map(ParallelFetch::processItem)
        .toList();

    List<String> results = allOfList(futures).join();
    System.out.println(results);
    // [processed-1, processed-2, processed-3, processed-4, processed-5]
}

The allOfList utility method is not in the standard library but is essential for real-world async code.

Error Handling

exceptionally: Provide a Fallback

CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
        if (Math.random() > 0.5) throw new RuntimeException("Service down");
        return "Success";
    })
    .exceptionally(ex -> {
        System.err.println("Caught: " + ex.getMessage());
        return "Fallback value";
    });

handle: Transform Both Success and Failure

CompletableFuture<String> result = fetchUser(1)
    .handle((value, ex) -> {
        if (ex != null) {
            return "Error: " + ex.getMessage();
        }
        return "Got: " + value;
    });

whenComplete: Side Effects Without Changing the Result

CompletableFuture<String> result = fetchUser(1)
    .whenComplete((value, ex) -> {
        if (ex != null) {
            logger.error("Failed to fetch user", ex);
        } else {
            logger.info("Fetched user: {}", value);
        }
    });

Retry Pattern

static <T> CompletableFuture<T> retry(
        java.util.function.Supplier<CompletableFuture<T>> taskSupplier,
        int maxRetries) {

    CompletableFuture<T> future = taskSupplier.get();
    for (int i = 0; i < maxRetries; i++) {
        future = future.exceptionallyCompose(ex -> {
            System.err.println("Retrying after: " + ex.getMessage());
            return taskSupplier.get();
        });
    }
    return future;
}

// Usage
CompletableFuture<String> result = retry(() -> fetchUnreliableService(), 3);

Timeouts

Java 9 added built-in timeout support:

import java.util.concurrent.*;

CompletableFuture<String> result = fetchUser(1)
    .orTimeout(2, TimeUnit.SECONDS);  // Throws TimeoutException

CompletableFuture<String> withDefault = fetchUser(1)
    .completeOnTimeout("Default User", 2, TimeUnit.SECONDS);  // Returns default

For more complex timeout strategies, combine with anyOf:

static <T> CompletableFuture<T> withFallbackTimeout(
        CompletableFuture<T> primary,
        CompletableFuture<T> fallback,
        long timeout, TimeUnit unit) {

    CompletableFuture<T> timedPrimary = primary
        .completeOnTimeout(null, timeout, unit);

    return timedPrimary.thenCompose(result -> {
        if (result != null) return CompletableFuture.completedFuture(result);
        return fallback;
    });
}

Executor Management

By default, supplyAsync and thenApplyAsync use the common ForkJoinPool. For I/O-bound work, use a dedicated executor:

import java.util.concurrent.*;

public class ExecutorManagement {
    // Dedicated executor for I/O operations
    private static final ExecutorService IO_EXECUTOR =
        Executors.newFixedThreadPool(20, r -> {
            Thread t = new Thread(r);
            t.setDaemon(true);
            t.setName("io-worker-" + t.getId());
            return t;
        });

    // With virtual threads (Java 21+)
    private static final ExecutorService VIRTUAL_EXECUTOR =
        Executors.newVirtualThreadPerTaskExecutor();

    static CompletableFuture<String> fetchFromDb(String query) {
        return CompletableFuture.supplyAsync(() -> {
            // Blocking I/O runs on the IO executor
            sleep(100);
            return "DB result for: " + query;
        }, IO_EXECUTOR);
    }

    static CompletableFuture<String> callExternalApi(String url) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(200);
            return "API response from: " + url;
        }, VIRTUAL_EXECUTOR);
    }

    public static void main(String[] args) {
        var result = fetchFromDb("SELECT * FROM users")
            .thenCombineAsync(
                callExternalApi("https://api.example.com"),
                (dbResult, apiResult) -> dbResult + " + " + apiResult,
                IO_EXECUTOR  // Specify where the combination runs
            )
            .join();

        System.out.println(result);
        IO_EXECUTOR.shutdown();
        VIRTUAL_EXECUTOR.shutdown();
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Pipeline Pattern

For complex async workflows, build pipelines as reusable method chains:

record OrderRequest(String customerId, List<String> productIds) {}
record ValidatedOrder(String customerId, List<String> productIds, double total) {}
record ProcessedOrder(String orderId, String status) {}

static CompletableFuture<ProcessedOrder> processOrder(OrderRequest request) {
    return validateCustomer(request.customerId())
        .thenCompose(valid -> checkInventory(request.productIds()))
        .thenCompose(available -> calculateTotal(request.productIds()))
        .thenApply(total -> new ValidatedOrder(
            request.customerId(), request.productIds(), total))
        .thenCompose(order -> chargePayment(order))
        .thenCompose(charged -> createShipment(charged))
        .exceptionally(ex -> new ProcessedOrder("NONE", "FAILED: " + ex.getMessage()));
}

Each step in the pipeline is a separate async operation. If any step fails, the exception propagates to exceptionally and the remaining steps are skipped.

Wrapping Up

CompletableFuture gives you a rich vocabulary for async composition. Use thenCompose for sequential async chains, allOf for parallel fan-out, and exceptionally or handle for error recovery. Always specify an executor for I/O-bound operations instead of relying on the common ForkJoinPool. With Java 21+ virtual threads, consider whether CompletableFuture pipelines are still necessary or whether straightforward blocking code on virtual threads serves you better. For many I/O-bound applications, virtual threads offer simpler code with equivalent scalability.