Backend EngineeringDecember 18, 20248 min read

Java Multithreading: CompletableFuture in Production

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.

JavaMultithreadingCompletableFutureSpring BootPerformance

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.

Parallel Service Calls

UserProfileService.java
java
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}

Timeout Handling

TimeoutExample.java
java
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.

Virtual Threads (Java 21)

VirtualThreads.java
java
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 needed

Exception Handling in CompletableFuture

ErrorHandling.java
java
1// 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    });

Chaining Async Operations

Chaining.java
java
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