Can you reuse a single CompletableFuture in multiple chains
In high-throughput distributed systems, reusing a CompletableFuture prevents the Thundering Herd or Cache Stampede problem, where hundreds of concurrent requests for the same mi...
🟢 Junior Level
Yes, absolutely. A single CompletableFuture instance can serve as the data source for any number of independent downstream chains and subscribers. In software architecture, this pattern is known as Fan-out (one producer, multiple consumers).
// Single asynchronous data source:
CompletableFuture<User> userFuture = userService.fetchUserAsync(userId);
// Branch 1: send welcome email
userFuture.thenAccept(user -> emailService.sendWelcome(user.getEmail()));
// Branch 2: populate user cache
userFuture.thenAccept(user -> userCache.put(user.getId(), user));
// Branch 3: collect analytics metrics
userFuture.thenRun(() -> metrics.increment("users.loaded"));
Key Properties of Fan-out:
- Single Execution: The background operation runs exactly once.
- Idempotence and Identity: All registered branches receive a reference to the exact same completed result.
- Late Subscription: You can attach new callbacks (
thenApply,thenAccept) either before the future completes or long after it has completed (in which case the callback executes immediately).
🟡 Middle Level
Request Collapsing Pattern (Thundering Herd Defense)
In high-throughput distributed systems, reusing a CompletableFuture prevents the Thundering Herd or Cache Stampede problem, where hundreds of concurrent requests for the same missing cache key bombard the primary database:
@Service
public class ProductService {
// In-flight request registry
private final ConcurrentMap<String, CompletableFuture<Product>> inFlight = new ConcurrentHashMap<>();
public CompletableFuture<Product> getProduct(String sku) {
return inFlight.computeIfAbsent(sku, key ->
// The first arriving thread initiates the actual database request:
databaseClient.fetchProductAsync(key)
// When complete (success or error), evict future from in-flight map:
.whenComplete((res, ex) -> inFlight.remove(key))
);
}
}
Result: If 100 concurrent HTTP requests simultaneously ask for product sku_123, exactly 1 database call is executed. All 100 callers attach to the same CompletableFuture and receive the result concurrently as soon as it resolves.
Danger: Shared Mutable State and Race Conditions
All consumer branches receive a reference to the same object in heap memory:
CompletableFuture<Order> orderFuture = fetchOrderAsync(id);
// Branch A (mutates shared domain entity):
orderFuture.thenAccept(order -> order.getDiscount().apply(0.5));
// Branch B (concurrently reads discount):
orderFuture.thenAccept(order -> billingService.calculate(order.getDiscount()));
// DATA RACE: Branch B may observe a partially or unpredictably mutated discount!
[!CAUTION] The result object inside a
CompletableFuturereused across multiple branches must be immutable (e.g., Javarecord, unmodifiable collections). If a branch requires modification, it must create an explicit defensive copy.
🔴 Senior Level
Execution Order: Treiber Stack and LIFO Behavior
When multiple synchronous callbacks are registered on an uncompleted CompletableFuture:
cf.thenRun(() -> System.out.println("First registered"));
cf.thenRun(() -> System.out.println("Second registered"));
cf.thenRun(() -> System.out.println("Third registered"));
Under the hood, CompletableFuture stores pending callbacks in a lock-free, single-linked Treiber stack (volatile Completion stack). Each callback registration executes a CAS operation pushing a new Completion node to the head of the stack.
Upon completion (cf.complete()), postComplete() unpacks the stack. In current OpenJDK implementations, callbacks execute in reverse registration order (LIFO):
"Third registered" -> "Second registered" -> "First registered".
[!WARNING] Never base business logic on the invocation order of sibling subscriber callbacks. The
CompletionStagespecification intentionally leaves execution order undefined. When strict sequencing is required, use explicit sequential composition viathenCompose().
Memory Leak Hazard in Long-Lived Root Futures
If an application maintains a global, long-lived CompletableFuture (e.g., application lifecycle listener or global event bus) and repeatedly attaches subscriber callbacks:
- Every call to
thenApply()orthenAccept()pushes an internalCompletionnode onto the root future’sstack. - The root future maintains strong references to all registered
Completionnodes and their captured closure scopes. - If the root future never completes (or retains callbacks after completion without cleanup), it creates an unbounded memory leak in heap memory, holding subscriber objects as GC roots.
4 Tricky Questions
1. In what order will callbacks execute if they are registered on the same CompletableFuture before its completion?
Answer:
In the standard OpenJDK implementation, synchronous callbacks execute in LIFO (Last-In, First-Out) order — meaning the callback registered last executes first.
This occurs because pending completion subscribers are pushed onto the head of a lock-free Treiber stack (volatile Completion stack) via CAS. When complete() calls postComplete(), it pops nodes from the top of the stack. However, the CompletionStage specification does not guarantee this order as part of its contract, so relying on this behavior in production logic is an anti-pattern.
2. If one consumer branch throws an unhandled exception, does it affect sibling branches attached to the same CompletableFuture?
Answer:
No, sibling branches are completely isolated.
Each transformation or consumption method (thenApply, thenAccept) creates and returns a new, independent CompletableFuture instance. If Branch A throws an unhandled runtime exception, only Branch A’s resulting downstream future transitions into an exceptionally completed state (AltResult). The root future and sibling Branch B remain healthy and continue uninterrupted.
3. What happens if a downstream branch calls childFuture.cancel(true)?
Answer:
Only that specific downstream branch is cancelled.
The root CompletableFuture continues executing in its background thread, and all other sibling downstream branches will successfully receive the root’s result. In CompletableFuture, cancellation propagates strictly downstream, never upstream or sideways across sibling branches.
4. In the Request Collapsing pattern with ConcurrentHashMap<String, CompletableFuture<Data>>, what happens if the database query fails and we forget to remove the key in whenComplete?
Answer:
This causes permanent Cache Poisoning.
The map retains a reference to a CompletableFuture completed exceptionally with CompletionException. Every subsequent request for that key will immediately return the cached failed future without querying the database, causing 100% failure for that key until the application restarts or the cache entry is explicitly cleared. Evicting the entry in whenComplete((res, ex) -> inFlight.remove(key)) is mandatory.
🎯 Interview Cheat Sheet
Summary in 60 Seconds
- A single
CompletableFuturecan be reused safely across multiple independent branches (Fan-out). - The root computation runs exactly once; all attached subscribers receive the identical result.
- Essential for Request Collapsing / Single-Flight patterns to eliminate redundant concurrent backend calls (preventing Cache Stampedes).
- Critical Requirement: The shared payload must be immutable (use Java
record) to avoid data races across parallel branches. - Execution order of sibling callbacks is LIFO in OpenJDK, but unspecified by the contract.
- Failures and cancellations in one branch do not propagate upstream or affect sibling branches.
Common Interview Traps
- Assuming each subscriber re-triggers the underlying
supplyAsynccomputation (it runs only once). - Mutating shared result objects inside one consumer callback, corrupting data read by concurrent sibling callbacks.
- Assuming
child.cancel()aborts the root future or sibling branches. - Forgetting to evict failed futures in request-deduplicating maps, causing cache poisoning.