CompletableFuture Exception Handling — exceptionally vs handle vs whenComplete
Fast recall
Quick revision
Core answer
CompletableFuture provides three recovery/observation hooks. `exceptionally(FunctionT; the cleanest "fallback on error" API. `handle(BiFunctionvalue and throwable (one is null); can transform type T → U and swallow the exception. `whenComplete(BiConsumerCompletionException at .join() time — unwrap with ex.getCause().Whether the candidate knows there are three hooks with distinct semantics (recover / recover-or-transform / observe) and understands that lost exceptions are a real production problem
Assuming `.exceptionally` runs on success too. Or forgetting to terminate a chain, losing exceptions. Or being confused by CompletionException wrapping
Mention CompletionException unwrapping, that whenComplete is observe-only (exception propagates), that exceptionally is bypassed on success, and that exceptionallyCompose (Java 12+) lets you fall back to another async
Interview-ready guide
Interview answer
CompletableFuture has three exception-handling hooks with distinct semantics.
`exceptionally(fn)` — recover on failure
Function- Runs only on upstream failure; success value passes through untouched
- Returns a replacement value; the resulting future succeeds
- Same type
Tin and out - Use for: "on error, fall back to cached/default value"
- Java 12+ adds
exceptionallyAsync(on executor) andexceptionallyCompose(async fallback)
`handle(biFn)` — transform both branches
BiFunction- Runs on both success and failure; one of the two params is null
- Can change type:
T → U - Returns new value; exception is swallowed unless biFn rethrows
- Use for: uniform response builder (wrap result or error into a Response object), metric recording with typed return
`whenComplete(biCons)` — observe, don't touch
BiConsumer- Runs on both branches
- Cannot change the result — pass-through
- Exception propagates downstream unchanged
- Use for: logging, metrics, audit trails
- If biCons itself throws, the new exception suppresses the original
Decision guide
- Need fallback value on error? →
exceptionally - Need to transform both branches into a unified result? →
handle - Need side effects without altering outcome? →
whenComplete
The silent-failure trap
- A chain with no terminator loses exceptions
future.thenCompose(x).thenApply(y);— returns void, exception vanishes- Fix: always end with
whenComplete+exceptionally, or return the future to someone who.join()s
CompletionException wrapping
- Inside exception hooks, the Throwable is typically wrapped
ex.getCause()to get the real exception- At
.join(): throwsCompletionException(unchecked); unwrap with getCause - At
.get(): throwsExecutionException(checked); same getCause dance
Common patterns
- Tap-then-recover:
.whenComplete(log)then.exceptionally(fallback)— log every outcome, fall back on errors - Typed filter: inside
exceptionally, checkex.getCause()type; rethrow others asCompletionException - Retry: extract a helper that composes
handle+ re-invocation of the async supplier
Recommendation: Treat every CompletableFuture chain like a promise chain in JavaScript — terminate it with a handler, or pass it to a caller who will. An unterminated chain that fails is indistinguishable from a successful one and creates the nastiest kind of silent bug. Use `exceptionally` for simple fallback, `handle` when you want to unify success and failure into one response type, `whenComplete` as a logging tap that doesn't change semantics. And always write a helper that unwraps `CompletionException` before matching on exception types — that code gets written once, used everywhere, and saves hours of debugging.
`CompletableFuture` Exception Handling — `exceptionally` vs `handle` vs `whenComplete`
Direct Answer
CompletableFuture provides three recovery/observation hooks. exceptionally(Function<Throwable, T>) — runs only on failure; returns a replacement value of type T; the cleanest "fallback on error" API. handle(BiFunction<T, Throwable, U>) — runs on both success and failure; sees both value and throwable (one is null); can transform type T → U and swallow the exception. whenComplete(BiConsumer<T, Throwable>) — runs on both but is observe-only: can't change the result, exception propagates downstream unchanged. Key rule: every CompletableFuture chain should terminate in exceptionally, handle, or have someone call .get()/.join() on it. Otherwise, exceptions vanish silently. Inside callbacks, the thrown exception is wrapped in a CompletionException at .join() time — unwrap with ex.getCause().
Common Mistakes & Pitfalls
Assuming .exceptionally runs on success too. Or forgetting to terminate a chain, losing exceptions. Or being confused by CompletionException wrapping
Check your understanding
Answer these without looking back at the article.
- What happens if the function inside `exceptionally` itself throws?
- How do you retry a failed CompletableFuture on a specific exception type?
- What's the difference between `whenComplete` and `handle` — why both?
- Why is the exception inside `exceptionally` often a `CompletionException`?
- How would you fall back to a backup service asynchronously on failure?
Related interview prep categories
2Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.