What happens if an exception occurs in CompletableFuture chain
When an exception (checked or runtime) is thrown at any stage in a CompletableFuture pipeline:
🟢 Junior Level
When an exception (checked or runtime) is thrown at any stage in a CompletableFuture pipeline:
- The current stage completes exceptionally: The exception is caught by the internal execution wrapper and packaged into a
CompletionException. - Short-Circuiting: All subsequent standard transformation and consumer stages (
thenApply,thenAccept,thenRun,thenCompose) are skipped; their lambda expressions are never invoked. - Error Cascading: The exceptional state propagates downstream through the completion graph until it encounters a recovery stage (
exceptionallyorhandle). - Terminal Methods Throw: Calling
.join()or.get()on any downstream stage following the unhandled failure will throw an exception (CompletionExceptionorExecutionException).
CompletableFuture.supplyAsync(() -> {
System.out.println("Step 1: generating order");
return "order_123";
})
.thenApply(orderId -> {
System.out.println("Step 2: validation error");
throw new IllegalArgumentException("Invalid order: " + orderId);
})
.thenApply(orderId -> {
// THIS STAGE IS SKIPPED!
System.out.println("Step 3: payment processing");
return "payment_success";
})
.thenAccept(status -> {
// THIS STAGE IS SKIPPED!
System.out.println("Step 4: sending receipt");
})
.exceptionally(ex -> {
// RECOVERY AND FALLBACK:
System.err.println("Pipeline recovered! Cause: " + ex.getMessage());
return null;
});
🟡 Middle Level
Error Isolation: Antecedent Stages and Sibling Branches Remain Healthy
In CompletableFuture, exceptions propagate strictly downstream. A failure in a downstream continuation stage has no retroactive effect on an antecedent stage that has already completed successfully:
CompletableFuture<String> step1 = CompletableFuture.supplyAsync(() -> "Database Payload");
CompletableFuture<String> step2 = step1.thenApply(data -> {
throw new RuntimeException("Crash on step 2");
});
// Inspect stage statuses:
System.out.println(step1.isCompletedExceptionally()); // false (successfully completed!)
System.out.println(step1.join()); // "Database Payload"
System.out.println(step2.isCompletedExceptionally()); // true (failed with exception!)
Pipeline Recovery
When an exception is intercepted by exceptionally() or handle(), the pipeline returns to a normal, successful state. Subsequent downstream stages resume standard execution using the fallback value:
CompletableFuture.supplyAsync(() -> { throw new RuntimeException("Primary service down"); })
.exceptionally(ex -> "Cached Fallback Data") // Intercepts error and returns fallback
.thenApply(String::toUpperCase) // EXECUTES: "CACHED FALLBACK DATA"
.thenAccept(System.out::println); // EXECUTES: prints to stdout
🔴 Senior Level
Internal Architecture: AltResult and Dependency Graph Traversal
Unlike standard synchronous Java code, CompletableFuture does not use JVM stack unwinding (athrow bytecode):
- Inside the worker task (
AsyncSupplyorUniApply), atry-catch(Throwable)block catches any error:catch (Throwable ex) { d.completeThrowable(ex); } - The
completeThrowablemethod wraps the exception into an internal container:boolean completeThrowable(Throwable x) { return RESULT.compareAndSet(this, null, encodeThrowable(x)); } static AltResult encodeThrowable(Throwable x) { return new AltResult((x instanceof CompletionException) ? x : new CompletionException(x)); } postComplete()traverses the dependent Treiber stack.- For standard transformation stages (
UniApply,UniAccept,UniCompose),tryFire()explicitly checks:if ((r = src.result) instanceof AltResult) { // Propagate the AltResult downstream without executing the user lambda: dep.result = encodeRelay(r); return true; } - Only recovery stages (
UniExceptionally,UniHandle) unwrapAltResultand invoke the recovery handler.
The Silent Failure Hazard
Because CompletableFuture captures all Throwable instances internally, exceptions never escape to the console, stderr, or Thread.UncaughtExceptionHandler:
// DANGEROUS SILENT FAILURE:
CompletableFuture.supplyAsync(() -> {
throw new OutOfMemoryError("Direct buffer overflow");
});
The worker thread finishes normally and returns to the pool to process other tasks. The error remains trapped within the result field of the future. If the reference is discarded without calling join(), get(), whenComplete(), or exceptionally(), the JVM garbage collector reclaims the object, and a critical system failure goes completely undetected.
4 Tricky Questions
1. What happens if a task in supplyAsync() throws a java.lang.Error (e.g., OutOfMemoryError or AssertionError)? Does the worker thread crash?
Answer:
The worker thread does not crash.
CompletableFuture catches Throwable, not just Exception (catch (Throwable ex)). Any error—including JVM Error subclasses—is intercepted, wrapped in AltResult(new CompletionException(error)), and stored in the result slot. The thread returns to the pool unharmed. While this prevents worker starvation in ForkJoinPool, it can mask catastrophic JVM failures unless futures are properly observed.
2. What happens if an exception is thrown inside an .exceptionally(fn) recovery block?
Answer: If the recovery function itself throws an exception $E_2$:
- The original exception $E_1$ is replaced by the new exception $E_2$.
- The returned
CompletableFuturetransitions to an exceptional state holding $E_2$. - All subsequent downstream stages are skipped again until another error handler (
exceptionallyorhandle) further down the chain is reached.
3. If a single root future feeds two independent branches, and one branch throws an exception, does it affect the other branch?
Answer:
CompletableFuture<String> root = CompletableFuture.supplyAsync(() -> "OK");
CompletableFuture<String> branchA = root.thenApply(s -> { throw new RuntimeException("A failed"); });
CompletableFuture<String> branchB = root.thenApply(s -> s + " -> B success");
No, the second branch is unaffected.
Branch branchA completes exceptionally, while branch branchB completes successfully with "OK -> B success". Each continuation creates an independent node in the dependency graph, isolating exceptional states to the failing branch.
4. Under what conditions does .join() throw CancellationException vs CompletionException?
Answer:
- If the stage was cancelled via
future.cancel(...),.join()throwsCancellationExceptiondirectly without wrapping. - If the stage failed due to an unhandled exception thrown in business logic or via
completeExceptionally(ex),.join()throwsCompletionException, wrapping the underlying cause in itsgetCause()property.
🎯 Interview Cheat Sheet
Summary in 60 Seconds
- Exceptions are caught as
Throwable, wrapped inCompletionException, and stored inresultasAltResult. - Standard downstream stages (
thenApply,thenAccept,thenCompose) are short-circuited and skipped until a recovery stage (exceptionally,handle) is encountered. - Failures propagate strictly downstream: antecedent stages and sibling branches remain unaffected.
- Unobserved futures produce silent failures: errors do not print to
stderror invoke thread uncaught exception handlers. - Calling
.join()throwsCompletionExceptionon task errors, and unwrappedCancellationExceptionon cancellation.
Common Interview Mistakes
- Believing that exceptions thrown in asynchronous tasks bubble up and crash pool worker threads.
- Assuming an error in a child stage cancels or invalidates the parent stage.
- Forgetting that an unhandled
CompletableFutureerror will be silently garbage-collected if no terminal consumer inspects it.