CompletableFuture patterns that actually appear in production code — parallel API calls, fan-out/fan-in, custom thread pools, timeout handling, and when to use Virtual Threads instead.
CompletableFuture is one of the most powerful but poorly understood APIs in Java. In production I use it daily for parallel service calls and fan-out patterns. Here are the patterns that matter.
1public UserProfile buildProfile(Long userId) {
2 // Fire both calls simultaneously instead of sequentially
3 CompletableFuture<User> userFuture = CompletableFuture
4 .supplyAsync(() -> userRepo.findById(userId));
5 CompletableFuture<List<Order>> ordersFuture = CompletableFuture
6 .supplyAsync(() -> orderRepo.findByUserId(userId));
7 CompletableFuture<List<Review>> reviewsFuture = CompletableFuture
8 .supplyAsync(() -> reviewRepo.findByUserId(userId));
9
10 // Wait for all and combine
11 return CompletableFuture
12 .allOf(userFuture, ordersFuture, reviewsFuture)
13 .thenApply(v -> UserProfile.builder()
14 .user(userFuture.join())
15 .orders(ordersFuture.join())
16 .reviews(reviewsFuture.join())
17 .build())
18 .join();
19}1// Java 9+ orTimeout and completeOnTimeout
2CompletableFuture<String> result = CompletableFuture
3 .supplyAsync(() -> externalApiCall())
4 .orTimeout(3, TimeUnit.SECONDS) // throws TimeoutException after 3s
5 .exceptionally(ex -> "fallback-value"); // handle timeout gracefully
6
7// completeOnTimeout: returns a default value instead of throwing
8CompletableFuture<String> withDefault = CompletableFuture
9 .supplyAsync(() -> externalApiCall())
10 .completeOnTimeout("default", 3, TimeUnit.SECONDS);Never use the common ForkJoinPool for blocking I/O calls. CompletableFuture.supplyAsync() uses ForkJoinPool.commonPool() by default. Pass your own Executor with IO-bound thread sizing: Executors.newFixedThreadPool(50) or a virtual thread executor in Java 21.
1// Java 21 — virtual threads for blocking I/O, no CompletableFuture needed
2try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
3 Future<User> userFuture = executor.submit(() -> userRepo.findById(userId));
4 Future<Order> ordersFuture = executor.submit(() -> orderRepo.findByUserId(userId));
5
6 User user = userFuture.get();
7 Order orders = ordersFuture.get();
8}
9
10// In Spring Boot 3.2+, just set:
11// spring.threads.virtual.enabled=true
12// All Tomcat request threads become virtual — no code changes needed1// exceptionally — handle exception and return a fallback value
2CompletableFuture<User> result = CompletableFuture
3 .supplyAsync(() -> userService.findById(userId))
4 .exceptionally(ex -> {
5 log.error("Failed to load user {}", userId, ex);
6 return User.anonymous(); // fallback
7 });
8
9// handle — runs whether success or failure, can inspect both
10CompletableFuture<User> handled = CompletableFuture
11 .supplyAsync(() -> userService.findById(userId))
12 .handle((user, ex) -> {
13 if (ex != null) return User.anonymous();
14 return user;
15 });
16
17// whenComplete — side effects only, doesn't change the value
18CompletableFuture<User> logged = CompletableFuture
19 .supplyAsync(() -> userService.findById(userId))
20 .whenComplete((user, ex) -> {
21 if (ex != null) metrics.increment("user.load.error");
22 else metrics.increment("user.load.success");
23 });1// thenApply — sync transform (same thread)
2// thenApplyAsync — async transform (new thread)
3CompletableFuture<OrderSummary> summary = CompletableFuture
4 .supplyAsync(() -> orderRepo.findById(orderId)) // fetch order
5 .thenApply(order -> enrichmentService.enrich(order)) // sync enrich
6 .thenApplyAsync(order -> pricingService.applyDiscount(order)) // async price
7 .thenCompose(order -> // flat-map to another CF
8 CompletableFuture.supplyAsync(() -> summaryBuilder.build(order)));thenApply vs thenCompose: use thenApply when the transformation returns a value directly. Use thenCompose when the transformation itself returns a CompletableFuture — it flattens the nested CF, like flatMap in streams.
More in Backend Engineering