Senior Java interviews often include a question like: “A CompletableFuture pipeline calls three services. One throws. What does the caller see, and how would you return a fallback for just that call?” The interviewer wants to know whether you understand how failures propagate through stages, which method recovers versus merely observes, and why instanceof checks on the exception often fail on the first try.
Asynchronous error handling is where many production bugs live: swallowed exceptions that leave a future incomplete forever, fallbacks applied to the wrong stage, and timeouts that never fire. A clear mental model avoids all three.
Before you start
You should know lambdas, checked versus unchecked exceptions, and basic CompletableFuture composition with supplyAsync, thenApply and thenCompose. Know that get() throws checked ExecutionException and join() throws unchecked CompletionException. Familiarity with thread pools helps, because stages run on whichever executor you supply or on the common ForkJoinPool by default.
The short answer
When a stage throws, its future completes exceptionally and every dependent thenApply, thenAccept or thenCompose is skipped; the failure flows downstream until a handler runs. exceptionally and handle can turn the failure into a normal value, while whenComplete only observes it and passes the original outcome on. Dependent stages see the exception wrapped in a CompletionException, so unwrap the cause before checking its type, and remember that join() rethrows it wrapped while get() wraps it in ExecutionException.
How it works
Every CompletableFuture holds either a result or an exceptional outcome. Each dependent stage registers a callback that runs when its source completes. Normal-path methods such as thenApply check the outcome first: if the source failed, they skip the function and complete their own future with the same failure. That is why one exception in the middle of a long chain jumps straight to the next handler.
CompletableFuture<Integer> f = CompletableFuture
.supplyAsync(() -> { throw new IllegalStateException("db down"); })
.thenApply(x -> (Integer) x + 1) // skipped
.thenApply(x -> x * 2) // skipped
.exceptionally(ex -> {
System.out.println(ex.getClass().getSimpleName()); // CompletionException
System.out.println(ex.getCause().getMessage()); // db down
return -1;
});
System.out.println(f.join()); // -1The wrapping rule: when a function inside a stage throws, the framework stores the exception wrapped in a CompletionException (unless it already is one). If you complete a future manually with completeExceptionally(new IOException()), that future holds the raw IOException, but stages that depend on it receive a CompletionException wrapping it. Writing handlers that unwrap defensively covers both cases.
The three handler families differ in what they can do:
| Method | Sees | Can recover | Output |
|---|---|---|---|
exceptionally(fn) |
failure only | yes | new value of same type |
handle(fn) |
value or failure | yes | any type |
whenComplete(action) |
value or failure | no | same outcome as source |
exceptionallyCompose(fn) (Java 12+) |
failure only | yes, asynchronously | a future |
Step-by-step walkthrough
Step 1: Write an unwrap helper
static Throwable unwrap(Throwable t) {
while ((t instanceof CompletionException || t instanceof ExecutionException)
&& t.getCause() != null) {
t = t.getCause();
}
return t;
}Use it in every handler that branches on exception type. Without it, ex instanceof TimeoutException is false because the visible exception is the wrapper.
Step 2: Recover close to the call that can fail
Attach the fallback directly to the stage it protects so unrelated failures are not masked:
CompletableFuture<Price> price = pricingClient.fetch(sku) // returns a future
.orTimeout(300, TimeUnit.MILLISECONDS) // Java 9+
.exceptionally(ex -> {
Throwable cause = unwrap(ex);
if (cause instanceof TimeoutException) {
return Price.cached(sku);
}
throw new CompletionException(cause); // rethrow others
});Rethrowing inside exceptionally keeps the stage failed. Wrapping in CompletionException avoids double wrapping and is legal for checked causes.
Step 3: Observe without changing the outcome
CompletableFuture<Order> order = placeOrder(cmd)
.whenComplete((result, ex) -> {
if (ex != null) {
log.warn("order failed for {}", cmd.id(), unwrap(ex));
} else {
metrics.increment("orders.placed");
}
});whenComplete returns a future with the same result or the same failure. If the action itself throws while the source succeeded, the returned future fails with the action’s exception; if the source had already failed, the original failure wins.
Step 4: Combine several futures
CompletableFuture<Price> p = fetchPrice(sku);
CompletableFuture<Stock> s = fetchStock(sku);
CompletableFuture<ProductView> view = p.thenCombine(s, ProductView::new)
.handle((v, ex) -> ex == null ? v : ProductView.unavailable(sku));thenCombine fails if either input fails. With CompletableFuture.allOf(a, b, c), the combined future fails as soon as all inputs have completed and at least one failed, but it does not cancel the others. Collect per-input fallbacks before allOf if you want partial results.
Worked scenario
A product page aggregates recommendations from a slow service. The first implementation:
CompletableFuture<List<Item>> recs = CompletableFuture
.supplyAsync(() -> recClient.load(userId))
.thenApply(this::rank);
recs.thenAccept(items -> page.setRecommendations(items));
return page;When recClient.load throws, thenAccept never runs and the exception is stored in a future nobody reads. There is no log and no fallback; the page silently renders without recommendations, and an incident takes hours to diagnose. When the service hangs instead, the future never completes, and because the default executor is the common pool, a few hung calls also starve every other parallelStream in the JVM.
The fixed version adds a dedicated executor, a timeout, logging and an explicit fallback, and it joins the result before rendering:
private final ExecutorService recPool = Executors.newFixedThreadPool(16);
CompletableFuture<List<Item>> recs = CompletableFuture
.supplyAsync(() -> recClient.load(userId), recPool)
.thenApply(this::rank)
.orTimeout(500, TimeUnit.MILLISECONDS)
.exceptionally(ex -> {
log.warn("recommendations unavailable for {}", userId, unwrap(ex));
return List.of();
});
page.setRecommendations(recs.join());Note that orTimeout completes the future exceptionally but does not interrupt the thread running recClient.load. The HTTP client still needs its own timeout so the pool thread is released.
Common mistake
- Checking the type without unwrapping.
ex instanceof IOExceptionis usually false becauseexis aCompletionException. - Expecting whenComplete to recover. It never changes a failure into a value. Use
handleorexceptionally. - Placing exceptionally too late. A single handler at the end of a long chain catches every failure and may return a fallback for a bug that should have surfaced.
- Believing cancel interrupts the task.
CompletableFuture.cancel(true)completes the future withCancellationException; themayInterruptIfRunningflag has no effect on the running code. - Fire-and-forget chains. A future that nobody joins or handles hides its failure completely.
Verify the behavior
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.*;
import org.junit.jupiter.api.Test;
class FutureErrorsTest {
@Test
void failureSkipsThenApplyAndIsWrapped() {
var f = CompletableFuture.supplyAsync(() -> { throw new IllegalStateException("x"); })
.thenApply(v -> "never");
var ex = assertThrows(CompletionException.class, f::join);
assertInstanceOf(IllegalStateException.class, ex.getCause());
}
@Test
void whenCompleteDoesNotRecover() {
var f = CompletableFuture.<String>failedFuture(new RuntimeException("boom"))
.whenComplete((v, e) -> {});
assertTrue(f.isCompletedExceptionally());
}
@Test
void handleRecovers() {
var f = CompletableFuture.<String>failedFuture(new RuntimeException("boom"))
.handle((v, e) -> e == null ? v : "fallback");
assertEquals("fallback", f.join());
}
}Follow-up questions
What is the difference between get() and join()? get() throws checked ExecutionException and InterruptedException; join() throws unchecked CompletionException, which is friendlier inside lambdas.
How do you add a timeout on Java 8? There is no orTimeout before Java 9. Use a scheduled executor that calls completeExceptionally(new TimeoutException()), or race the future with applyToEither.
When would you use exceptionallyCompose? When recovery itself is asynchronous, for example retrying a call or falling back to a secondary service that returns a future.
Which thread runs a non-async handler? Either the thread that completed the source, or the calling thread if the source was already complete when the handler was attached. Use the ...Async variants with an executor if handlers do blocking work.
Interview exercise
You call allOf(a, b, c).join() and b fails after 50 ms while a and c take two seconds. When does join() throw, and how would you return as soon as any call fails?
Answer and reasoning
allOf completes only when all inputs complete, so join() throws after about two seconds, with a CompletionException caused by b’s exception. The early failure does not short-circuit and does not cancel a or c. To fail fast, attach a handler to each input that completes a shared result future exceptionally on the first failure:
static CompletableFuture<Void> allOrFirstFailure(CompletableFuture<?>... fs) {
CompletableFuture<Void> all = CompletableFuture.allOf(fs);
for (CompletableFuture<?> f : fs) {
f.whenComplete((v, ex) -> { if (ex != null) all.completeExceptionally(ex); });
}
return all;
}completeExceptionally on the allOf future wins if it runs first, so the caller sees the failure after about 50 ms. You would still cancel or time out the remaining calls at the client level, because completing a future does not stop the work behind it.
Continue learning
Practise more concurrency questions in the Java interview questions and the Java MCQs. Related notes: executor shutdown and task lifecycle and virtual threads for blocking work. The complete method contract is in the CompletableFuture API documentation.