Working with asynchronous programming in Java used to be tricky with just Thread, Runnable, or ExecutorService. Since Java 8, CompletableFuture has become one of the most powerful APIs for handling asynchronous tasks with clean, fluent, and non-blocking code.
In this blog, weβll explore:
- Basics of
CompletableFuture - Real-world use cases
- Chaining and combining futures
- Exception handling
- Running multiple tasks in parallel
1. Getting Started with CompletableFuture
A CompletableFuture represents a future result of an asynchronous computation. It can be manually completed or executed asynchronously.
Example: Running an async task
import java.util.concurrent.*;
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
try {
Thread.sleep(1000);
System.out.println("Task executed in: " + Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
}
});
future.get(); // wait until completion
}
}
π Key point: runAsync() runs a Runnable (no return value), while supplyAsync() runs a Supplier (with return value).
2. Returning Results with supplyAsync
import java.util.concurrent.*;
public class CompletableFutureReturn {
public static void main(String[] args) throws Exception {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) { }
return "Hello from " + Thread.currentThread().getName();
});
System.out.println(future.get()); // blocks until result is available
}
}
3. Chaining with thenApply, thenAccept, thenRun
-
thenApply()β transform result and return new value -
thenAccept()β consume result (no return) -
thenRun()β run after completion (ignores result)
import java.util.concurrent.*;
public class CompletableFutureChaining {
public static void main(String[] args) {
CompletableFuture.supplyAsync(() -> "Data")
.thenApply(data -> data + " processed")
.thenAccept(System.out::println)
.thenRun(() -> System.out.println("Pipeline finished!"));
}
}
Output:
Data processed
Pipeline finished!
4. Combining Multiple Futures
Example: thenCombine (merge two results)
import java.util.concurrent.*;
public class CompletableFutureCombine {
public static void main(String[] args) throws Exception {
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "World");
CompletableFuture<String> combined = future1.thenCombine(future2, (a, b) -> a + " " + b);
System.out.println(combined.get()); // Hello World
}
}
Example: allOf (wait for multiple tasks)
import java.util.concurrent.*;
public class CompletableFutureAllOf {
public static void main(String[] args) throws Exception {
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "Task1");
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "Task2");
CompletableFuture<String> f3 = CompletableFuture.supplyAsync(() -> "Task3");
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
all.join();
System.out.println(f1.get() + ", " + f2.get() + ", " + f3.get());
}
}
5. Handling Exceptions
Using exceptionally
public class CompletableFutureException {
public static void main(String[] args) throws Exception {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (true) throw new RuntimeException("Something went wrong!");
return "OK";
}).exceptionally(ex -> "Recovered from: " + ex.getMessage());
System.out.println(future.get()); // Recovered from: Something went wrong!
}
}
Using handle
public class CompletableFutureHandle {
public static void main(String[] args) throws Exception {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (true) throw new RuntimeException("Error occurred!");
return "Success";
}).handle((result, ex) -> {
if (ex != null) return "Fallback value";
return result;
});
System.out.println(future.get()); // Fallback value
}
}
6. Real-World Use Case: Fetching Data from Multiple APIs
Imagine you want to fetch product details and pricing from different services in parallel.
import java.util.concurrent.*;
public class ProductService {
public static void main(String[] args) throws Exception {
CompletableFuture<String> productFuture = CompletableFuture.supplyAsync(() -> {
sleep(1000);
return "Product details";
});
CompletableFuture<String> priceFuture = CompletableFuture.supplyAsync(() -> {
sleep(1500);
return "Price details";
});
CompletableFuture<String> finalResult =
productFuture.thenCombine(priceFuture, (product, price) -> product + " + " + price);
System.out.println("Final Result: " + finalResult.get());
}
private static void sleep(int ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { }
}
}
7. When to Use CompletableFuture
β Best for:
- Running I/O-bound tasks in parallel (API calls, DB queries)
- Building pipelines of transformations
- Handling dependent async tasks
- Aggregating results from multiple computations
β Avoid for:
- Purely CPU-bound heavy computations (better use
ForkJoinPoolor parallel streams) - Extremely short tasks (overhead may outweigh benefits)
π Conclusion
CompletableFuture simplifies asynchronous programming in Java by making code:
- Readable (fluent chaining)
- Composable (combine tasks easily)
- Robust (built-in exception handling)
By using patterns like thenCombine, allOf, and exceptionally, you can build efficient non-blocking pipelines for real-world applications.
π Do you want me to also include advanced use cases like timeouts (orTimeout), cancellation, and using a custom ExecutorService in this blog?
Top comments (0)