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.
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.
runAsyncrunAsync is used to create a CompletableFuture that does not return a value.
CompletableFuture<Void> fireAndForget = CompletableFuture.runAsync(() -> { auditService.log("event"); });
supplyAsyncsupplyAsync 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
runAsyncandsupplyAsyncmethods 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);
completedFuturecompletedFuture 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));
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.
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.
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; });
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);
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, 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.
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.
Learn parallel task execution in Java and Spring Boot using CompletableFuture, @Async, Virtual Threads, and Structured Concurrency for better performance.
Learn about the Structured concurrency API in Java 25, new way to write concurrent code

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