CompletableFuture — Advanced Patterns
AdvancedAdvanced CompletableFuture patterns — custom executors, timeout handling, retry logic, bulkhead isolation, and combining complex async pipelines.
Overview
Beyond the basics, CompletableFuture enables sophisticated async patterns: retry with exponential backoff, circuit breakers, bulkhead isolation (separate thread pools per service), fan-out/fan-in with partial results, and graceful degradation. Java 9 added orTimeout() and completeOnTimeout() for deadline handling. Understanding when to complete a future exceptionally vs. completing with a fallback is key for resilient async code.
Custom Executors and Bulkhead Pattern
The default ForkJoinPool.commonPool() is shared by all parallel streams, CompletableFuture.supplyAsync() calls, and other framework code. Saturating it with slow I/O tasks blocks the whole application.
The bulkhead pattern isolates work into separate thread pools — a slow dependency can only exhaust its own pool, not affect others.
// Separate executors per external dependency (bulkhead)
ExecutorService dbPool = Executors.newFixedThreadPool(20);
ExecutorService paymentPool = Executors.newFixedThreadPool(5);
ExecutorService emailPool = Executors.newVirtualThreadPerTaskExecutor();
public CompletableFuture<OrderResult> placeOrderAsync(OrderRequest req) {
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> userDb.find(req.userId()), dbPool);
CompletableFuture<Payment> payFuture =
CompletableFuture.supplyAsync(() -> payment.charge(req), paymentPool);
return userFuture.thenCombineAsync(payFuture, (user, pay) -> {
Order order = orderDb.save(new Order(user, pay));
// Email on separate pool — doesn't block order completion
CompletableFuture.runAsync(
() -> emailService.sendConfirmation(user, order), emailPool);
return new OrderResult(order.id(), pay.txnId());
}, dbPool); // combine result on DB pool
}Retry with Exponential Backoff
Retry logic wraps an async operation: on failure, schedule a retry after a delay (exponential backoff with jitter avoids thundering herd). CompletableFuture.delayedExecutor() (Java 9+) schedules execution after a delay without blocking a thread.
import java.util.concurrent.*;
public static <T> CompletableFuture<T> withRetry(
Supplier<CompletableFuture<T>> operation,
int maxAttempts,
Duration initialDelay,
ScheduledExecutorService scheduler) {
CompletableFuture<T> result = new CompletableFuture<>();
attempt(operation, maxAttempts, initialDelay, scheduler, result, 1);
return result;
}
private static <T> void attempt(
Supplier<CompletableFuture<T>> operation, int maxAttempts,
Duration delay, ScheduledExecutorService scheduler,
CompletableFuture<T> result, int attempt) {
operation.get().whenComplete((value, ex) -> {
if (ex == null) {
result.complete(value);
} else if (attempt >= maxAttempts) {
result.completeExceptionally(ex);
} else {
// Exponential backoff with jitter
long millis = delay.toMillis() * (1L << (attempt - 1));
long jitter = ThreadLocalRandom.current().nextLong(millis / 2);
scheduler.schedule(
() -> attempt(operation, maxAttempts, delay, scheduler, result, attempt + 1),
millis + jitter, TimeUnit.MILLISECONDS);
}
});
}
// Usage
withRetry(() -> httpClient.sendAsync(request, ofString()), 3,
Duration.ofMillis(200), scheduler)
.thenAccept(resp -> System.out.println("Got: " + resp.body()));Fan-Out / Fan-In with Partial Results
Fan-out: dispatch N parallel async calls. Fan-in: collect results. With allOf(), you wait for all — but one slow/failing request blocks everything. For resilient fan-in, collect partial results even when some futures fail.
// Fan-out to multiple services
List<String> serviceUrls = List.of(url1, url2, url3, url4, url5);
List<CompletableFuture<String>> futures = serviceUrls.stream()
.map(url -> CompletableFuture
.supplyAsync(() -> callService(url), ioPool)
.orTimeout(2, TimeUnit.SECONDS) // per-request timeout
.exceptionally(ex -> null)) // failures → null
.collect(Collectors.toList());
// Fan-in: collect all results (including nulls for failures)
CompletableFuture<List<String>> allResults = CompletableFuture
.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> futures.stream()
.map(CompletableFuture::join) // all done — join is safe
.filter(Objects::nonNull) // drop failed requests
.collect(Collectors.toList()));
// Result: successful responses only, within 2s deadline
List<String> results = allResults
.orTimeout(3, TimeUnit.SECONDS) // overall timeout
.join();
System.out.println("Got " + results.size() + "/" + serviceUrls.size() + " results");Key Points to Remember
- Use separate thread pools per external dependency (bulkhead) to prevent cascade failures.
- completeOnTimeout() provides a fallback value on timeout; orTimeout() completes exceptionally.
- delayedExecutor() schedules future execution without blocking a thread.
- Retry with exponential backoff + jitter prevents thundering herd on service recovery.
- Fan-in with partial results: use exceptionally(ex -> null) to collect successes only.
Practice CompletableFuture — Advanced Patterns in the Playground
Run and modify code directly in your browser - no setup needed.
Interview Questions
Sign in to ask AriaWhat is the bulkhead pattern and how do you implement it with CompletableFuture?
What is the difference between orTimeout() and completeOnTimeout()?
How would you implement retry with exponential backoff using CompletableFuture?
How do you collect partial results from a fan-out when some futures fail?
Why is it dangerous to use ForkJoinPool.commonPool() for I/O-heavy async tasks?
Ask Aria about CompletableFuture — Advanced Patterns
Your personal AI tutor — ask anything about this concept