CompletableFuture: A Complete Practical Guide.

CompletableFuture, part of Java’s java.util.concurrent package, is an incredibly powerful class for managing asynchronous tasks and handling complex workflows. It allows you to create, combine, and handle async operations without blocking threads, making it ideal for building responsive applications.

In this guide, we’ll walk through 10 practical examples that demonstrate how CompletableFuture can be used in real-world scenarios. By the end, you’ll have a solid grasp of how to use CompletableFuture for concurrent tasks, exception handling, and performance optimization.


1. Simple Asynchronous Task Execution

A basic example of CompletableFuture is running a task asynchronously, such as fetching data from an external source.

CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
    System.out.println("Fetching data asynchronously: " + Thread.currentThread().getName());
});
future.join(); // Block and wait for completion

Explanation: This code runs a task asynchronously on a separate thread, printing a message. join() is used to wait for completion, blocking the main thread until the async task finishes.


2. Returning a Result with Async Computation

Use supplyAsync to run a task that returns a result, such as fetching user data.

CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> {
    System.out.println("Fetching user data...");
    return "User Data";
});

String result = userFuture.join(); // Gets "User Data" after async completion
System.out.println("Result: " + result);

Explanation: This example shows how to run a task that produces a result ("User Data"). supplyAsync enables returning a value from an async operation.


3. Chaining Async Tasks with thenApply

Chaining tasks is essential for workflows where the output of one task is needed for the next.

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 50)
        .thenApply(amount -> amount * 2); // Doubles the amount

System.out.println("Processed result: " + future.join());

Explanation: Here, thenApply processes the result of a previous task (doubling a value). It returns a new CompletableFuture with the transformed result.


4. Combining Two Futures

Use thenCombine to combine results from two separate async tasks, such as fetching user and profile data.

CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> "John");
CompletableFuture<String> profileFuture = CompletableFuture.supplyAsync(() -> "Developer");

CompletableFuture<String> combinedFuture = userFuture.thenCombine(profileFuture, 
    (user, profile) -> user + " - " + profile);

System.out.println("Combined result: " + combinedFuture.join());

Explanation: This combines results from two futures. thenCombine waits for both to complete, then merges their outputs, producing "John - Developer".


5. Handling Errors with Exceptionally

Handle exceptions in async tasks to provide a fallback or recover gracefully.

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    if (true) throw new RuntimeException("Failed to fetch data");
    return "Data";
}).exceptionally(ex -> "Fallback Data");

System.out.println("Result: " + future.join());

Explanation: If an exception is thrown, exceptionally provides "Fallback Data" as a safe result.


6. Retrying on Failure

Using recursive CompletableFutures, you can retry a task when it fails.

import java.util.concurrent.CompletableFuture;

public class RetryExample {

    public static CompletableFuture<String> fetchDataWithRetry(int retries) {
        return CompletableFuture.supplyAsync(() -> {
            if (Math.random() < 0.7) throw new RuntimeException("Temporary issue");
            return "Fetched Data";
        }).exceptionally(ex -> retries > 0 ? fetchDataWithRetry(retries - 1).join() : "Failed after retries");
    }

    public static void main(String[] args) {
        String result = fetchDataWithRetry(3).join();
        System.out.println("Result: " + result);
    }
}

Explanation: If a task fails, exceptionally attempts to retry by calling itself recursively with decremented retries.


7. Timeout Management

Set a timeout to avoid endless waits, useful for external services that may hang.

import java.util.concurrent.*;

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    try { Thread.sleep(3000); } catch (InterruptedException e) {}
    return "Result";
});

String result = future.orTimeout(2, TimeUnit.SECONDS)
                      .exceptionally(ex -> "Timeout").join();

System.out.println("Result: " + result);

Explanation: If the task exceeds the 2-second limit, the exceptionally block handles the timeout, returning "Timeout".


8. Parallel Processing with AllOf

Process multiple tasks in parallel and wait for all to complete.

CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Task 1");
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "Task 2");
CompletableFuture<String> future3 = CompletableFuture.supplyAsync(() -> "Task 3");

CompletableFuture<Void> allOf = CompletableFuture.allOf(future1, future2, future3);

allOf.thenRun(() -> {
    System.out.println(future1.join());
    System.out.println(future2.join());
    System.out.println(future3.join());
}).join();

Explanation: allOf waits for all tasks to complete before proceeding, making it useful for aggregating results from multiple async tasks.


9. AnyOf for First Completed Task

Use anyOf when only one task needs to complete to proceed, ideal for race conditions.

CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
    try { Thread.sleep(100); } catch (InterruptedException e) {}
    return "First";
});
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "Second");

CompletableFuture<Object> anyOf = CompletableFuture.anyOf(future1, future2);

System.out.println("First completed: " + anyOf.join());

Explanation: anyOf completes as soon as any task finishes, making it efficient when only the first result matters.


10. Pipelining Dependent Tasks

Pipelining allows you to create a sequence of async tasks where each depends on the previous task’s result, useful for multi-step workflows.

CompletableFuture<Void> pipeline = CompletableFuture.supplyAsync(() -> "Stage 1")
        .thenApply(stage1 -> stage1 + " -> Stage 2")
        .thenApply(stage2 -> stage2 + " -> Stage 3")
        .thenAccept(result -> System.out.println("Pipeline result: " + result));

pipeline.join();

Explanation: This example creates a multi-step async pipeline where each stage builds upon the last, producing "Stage 1 -> Stage 2 -> Stage 3" at the end.


Conclusion

CompletableFuture provides a powerful framework for managing asynchronous programming in Java, allowing developers to create complex workflows, handle errors gracefully, and optimize performance. With these 10 practical examples, you can start applying CompletableFuture to real-world scenarios in your applications to improve responsiveness, scalability, and code readability.

More such articles:

medium.com/techwasti

youtube.com/@maheshwarligade

techwasti.com/series/spring-boot-tutorials

techwasti.com/series/go-language

Did you find this article valuable?

Support techwasti by becoming a sponsor. Any amount is appreciated!