Java Completable Future Deep Dive : A Comprehensive Guide

    Java Completable Future Deep Dive : A Comprehensive Guide

    09/11/2025

    Introduction

    CompletableFuture has been part of the Java toolbox since Java 8 and provides a powerful API for asynchronous programming on the JVM. It lets you compose asynchronous pipelines, control threading, and coordinate multiple tasks without directly dealing with low-level concurrency APIs. In this blog post, we will cover the core API features and then see how it fits alongside virtual threads and structured concurrency in modern Java.

    What is CompletableFuture?

    CompletableFuture is a class in the java.util.concurrent package which implements the Future and CompletionStage interfaces. It provides a rich API for asynchronous programming on the JVM. You can think of it as a container for a future result of an asynchronous operation.

    Creating Futures

    1.runAsync

    runAsync is used to create a CompletableFuture that does not return a value.

    CompletableFuture<Void> fireAndForget = CompletableFuture.runAsync(() -> { auditService.log("event"); });

    2.supplyAsync

    supplyAsync is used to create a CompletableFuture that returns a value. It takes a Supplier<T> as input which returns the result of the task. It returns a CompletableFuture<T> where T is the type of the value returned by the Supplier.

    To get the result of the CompletableFuture, we can call the get method on it, but this will block the current thread until the result is available.

    CompletableFuture<String> fetchData = CompletableFuture.supplyAsync(() -> { return httpClient.fetch("https://api.example.com/data"); }); String data = fetchData.get(); // Blocking call System.out.println(data);

    Both runAsync and supplyAsync methods use the common ForkJoinPool by default. Pass a custom executor when you need different sizing.

    ExecutorService executor = Executors.newFixedThreadPool(10); CompletableFuture<OrderDetails> orderFuture = CompletableFuture.supplyAsync(() -> orderClient.fetch(orderId), executor);

    3.completedFuture

    completedFuture is useful when you already have a value and want to seed a pipeline without scheduling work. It surfaces in testing and in branching flows where one side has a constant result.

    CompletableFuture<Customer> cachedCustomer = CompletableFuture.completedFuture(customerCache.get(customerId));

    Creating Pipelines

    Below are some commonly used methods for chaining mutliple CompletableFuture objects to create a pipeline.

    • thenApply: transform the result synchronously. This method is used when we want to apply some transformation on the result of the task.
    • thenApplyAsync: transform the result asynchronously. This method is used when we want to apply some transformation on the result of the task asynchronously.
    • thenCompose: flatten nested futures (think async flatMap). This method is used when we want to chain multiple CompletableFuture and one task depends on the result of the previous task.
    • thenAccept / thenRun: consume results without returning a value.
    CompletableFuture<BigDecimal> priceFuture = orderFuture .thenApply(OrderDetails::price) // Transform the result synchronously .thenCompose(price -> exchangeClient.convert(price, "USD")) // Chain multiple `CompletableFuture`s and one task depends on the result of the previous task .thenApply(converted -> converted.multiply(new BigDecimal("0.85"))) // Transform the result synchronously

    Use thenCompose whenever the chained method produces a CompletableFuture. In the above example, we are calling exchangeClient.convert(price, "USD") which returns a CompletableFuture<BigDecimal>. So we are using thenCompose to chain the CompletableFuture objects.

    Use thenApply when the chained method does not produce a CompletableFuture. In the above example, we are calling OrderDetails::price which returns a BigDecimal. So we are using thenApply to chain the CompletableFuture objects.

    Combining Multiple Tasks

    Below are some commonly used methods for combining multiple CompletableFuture objects.

    • allOf: wait for all futures to complete.
    • anyOf: resolve when the first completes.
    • thenCombine: combine two futures’ results.
    CompletableFuture<User> userFuture = userClient.fetch(userId); CompletableFuture<List<Order>> ordersFuture = orderClient.fetchOrders(userId); CompletableFuture<UserOrders> combined = userFuture.thenCombine(ordersFuture, (user, orders) -> new UserOrders(user, orders)); CompletableFuture<Void> allDone = CompletableFuture.allOf(userFuture, ordersFuture, auditFuture);

    Collecting results from allOf requires unpacking the individual futures:

    CompletableFuture<List<ProductPrice>> pricesFuture = CompletableFuture .allOf(priceFutures.toArray(CompletableFuture[]::new)) .thenApply(v -> priceFutures.stream() .map(CompletableFuture::join) .toList());

    anyOf finishes when the first future wins. Its result is typed as Object, so cast as needed.

    CompletableFuture<Object> fastest = CompletableFuture.anyOf(searchFromCache(q), searchFromDb(q), searchFromApi(q)); String winningResult = (String) fastest.join();

    join() retrieves the completed result without forcing you to catch checked exceptions; it mirrors get() but wraps failures in an unchecked CompletionException.

    Exception Handling Strategies

    Below are some commonly used methods for handling exceptions in CompletableFuture.

    • exceptionally: recover with a fallback value.
    • handle: inspect both success and failure.
    CompletableFuture<String> profileFuture = profileClient.fetch(userId) .exceptionally(ex -> { metrics.increment("profile.fallback"); return "{}"; // degraded response }); CompletableFuture<Order> safeOrderFuture = orderClient.fetch(orderId) .handle((result, ex) -> { if (ex != null) { log.error("Failed to fetch order {}", orderId, ex); throw new OrderUnavailableException(orderId, ex); } return result; });

    Timeouts

    We can use orTimeout to set a timeout on the CompletableFuture and if task doesn't complete within the timeout, it will throw a TimeoutException.

    If you don't want to throw an exception, you can use completeOnTimeout to set a default value if the task doesn't complete within the timeout.

    CompletableFuture<Product> productFuture = productClient.fetchAsync(productId) .orTimeout(500, TimeUnit.MILLISECONDS) .exceptionally(ex -> { throw new ProductTimeoutException(productId, ex); }); CompletableFuture<Product> defaulted = productClient.fetchAsync(productId) .completeOnTimeout(Product.defaultProduct(), 300, TimeUnit.MILLISECONDS);

    Virtual Threads with CompletableFuture

    Java 21 introduced virtual threads, allowing use to run millions of threads in a normal commodity machine. CompletableFuture works seamlessly with virtual threads:

    We just need to use Executors.newVirtualThreadPerTaskExecutor() to create a virtual thread executor and pass it to the supplyAsync method.

    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { CompletableFuture<String> salesReport = CompletableFuture.supplyAsync(() -> reportService.generate("sales"), executor); CompletableFuture<String> inventoryReport = CompletableFuture.supplyAsync(() -> reportService.generate("inventory"), executor); CompletableFuture<String> revenueReport = CompletableFuture.supplyAsync(() -> reportService.generate("revenue"), executor); CompletableFuture.allOf(salesReport, inventoryReport, revenueReport).join(); List<String> reports = List.of( salesReport.get(), inventoryReport.get(), revenueReport.get()); }

    Structured Concurrency - A Cleaner Way to Handle Concurrency

    Structured concurrency, still a preview feature in Java 25 provides a better and cleaner way to handle concurrency. We can expect most of the use cases for which we use CompletableFuture to be replaced by StructuredTaskScope in the future.

    Further Reading: Learn about the new Structured Concurrency API in Java 25 for a modern and safer approach to managing concurrency, cancellation, and result aggregation.

    When to use Completable Future now

    • Parallelising independent I/O-bound calls such as fetching data from multiple APIs.
    • Orchestrating CPU-bound work where results feed into each other.
    • Bridging callback-based APIs into a fluent, composable model.
    • Implementing timeouts, retries, and fallbacks around asynchronous operations.

    Conclusion

    CompletableFuture is a powerful API for asynchronous programming on the JVM. It lets you compose asynchronous pipelines, control threading, and coordinate multiple tasks without dropping down to low-level concurrency APIs. It is a great tool to have in your toolbox when you need to write asynchronous code in Java.

    To stay updated with the latest updates in Java and Spring, follow us on LinkedIn and Medium.

    Summarise

    Transform Your Learning

    Get instant AI-powered summaries of YouTube videos and websites. Save time while enhancing your learning experience.

    Instant video summaries
    Smart insights extraction
    Channel tracking