Compare exceptionally, handle, and whenComplete on CompletableFuture. When would you reach for each, and how do they differ in what they receive and what they produce?
answer
- exceptionally = error-only fallback
- handle = both paths, returns value, recovers
- whenComplete = side-effect, pass-through, no recover
- handle/whenComplete: exactly one of (value, throwable) is non-null
- whenComplete is a BiConsumer (no return)
basics
~20 sexceptionally runs only on failure and supplies a fallback value. handle runs on both success and failure and returns a new value. whenComplete runs on both for a side-effect (like logging) but cannot change the result.
solid answer
~50 sAll three are completion callbacks but differ in trigger, input, and output. exceptionally(fn) fires only when the stage failed; it receives the exception and returns a replacement value, recovering the chain. handle(fn) always fires and receives both (result, throwable) — exactly one is non-null — and must return a value, so it can recover OR transform either case. whenComplete(fn) always fires, receives (result, throwable), but returns nothing useful: it is purely a side-effect (logging, metrics, cleanup) and passes the original outcome through unchanged — if the stage failed, the failure still propagates. So: use whenComplete to observe without altering, handle to map both outcomes to a value, exceptionally to recover only the error path. Note exceptionally and handle have no built-in async-on-error nuance: handle/whenComplete run even on success, which can surprise people expecting error-only behavior.
code
java · 14 linesCompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> {
throw new IllegalStateException("boom");
});
// exceptionally: error-only fallback
cf.exceptionally(ex -> -1); // -> completes with -1
// handle: both paths, returns a value (recovers)
cf.handle((value, ex) -> ex != null ? -1 : value * 2);
// whenComplete: side-effect only, failure still propagates
cf.whenComplete((value, ex) -> {
if (ex != null) System.out.println("failed: " + ex);
}); // downstream still sees IllegalStateException (wrapped)go deeper
Can state that exceptionally handles errors and the other two run on completion; may blur the differences.
Correctly distinguishes all three on trigger, inputs, and whether they recover; knows whenComplete is a pass-through side-effect.
Explains the exactly-one-non-null contract, that handle changes result type, and the whenComplete-action-throws suppression rule; picks the right one per intent.
Discusses async variants, executor/thread-of-execution implications, and designs error-handling conventions across a codebase (e.g. centralized logging via whenComplete plus recovery via handle).
## Background A `CompletableFuture<T>` represents an asynchronous computation that will *eventually* complete either **normally** (with a value of type `T`) or **exceptionally** (with a `Throwable`). You attach *callbacks* (called *stages*) that run when it completes. Three callbacks deal with completion and errors: `exceptionally`, `handle`, and `whenComplete`. Understanding them means knowing three axes for each: **when does it run**, **what does it receive**, and **what does the resulting stage hold**. ## exceptionally Signature: `CompletableFuture<T> exceptionally(Function<Throwable, ? extends T> fn)`. - **Runs:** only if the upstream stage completed *exceptionally*. If upstream succeeded, the function is skipped entirely and the value passes straight through. - **Receives:** the `Throwable` (the cause that broke the chain). - **Produces:** a value of the **same type** `T` — a fallback/recovery value. The returned stage is now *normally* completed with that fallback, so downstream stages see success. Think of it as a catch block that yields a default value. Example: `cf.exceptionally(ex -> -1)` turns any failure into `-1`. ## handle Signature: `<U> CompletableFuture<U> handle(BiFunction<? super T, Throwable, ? extends U> fn)`. - **Runs:** *always* — on both success and failure. - **Receives:** **both** the result and the throwable. Exactly **one is non-null**: on success `(value, null)`, on failure `(null, throwable)`. You must null-check the throwable to know which case you're in. - **Produces:** a new value of (possibly new) type `U`. Because it always returns a value, `handle` *recovers* from failures (downstream sees normal completion) AND can *transform* the success value. It is the most general of the three. Example: ```java cf.handle((value, ex) -> ex != null ? "fallback" : value.toUpperCase()); ``` ## whenComplete Signature: `CompletableFuture<T> whenComplete(BiConsumer<? super T, ? super Throwable> action)`. - **Runs:** *always* — on both success and failure. - **Receives:** both result and throwable, same null pattern as `handle`. - **Produces:** **nothing** (it is a `BiConsumer`, not a function). The resulting stage carries the **original outcome unchanged**: if upstream succeeded, the same value flows on; if upstream failed, the *same* exception still propagates. `whenComplete` is for **side-effects only** — logging, metrics, releasing resources. It does *not* recover. A subtle rule: if `whenComplete`'s own action throws, that new exception is recorded *only if* upstream succeeded; if upstream already failed, the original exception wins and the action's exception is suppressed. ## Summary table | | runs on success | runs on failure | recovers? | can transform value? | |---|---|---|---|---| | `exceptionally` | no | yes | yes | no (only error path) | | `handle` | yes | yes | yes | yes (both paths) | | `whenComplete` | yes | yes | **no** | no (pass-through) | ## Choosing - Want to **log/measure** without changing the result → `whenComplete`. - Want to **provide a fallback only on error** → `exceptionally`. - Want to **map both outcomes** to a final value (recover + transform) → `handle`. Each also has `*Async` variants (e.g. `handleAsync`) that run the callback on a different executor instead of the completing thread.
- What happens if the action passed to whenComplete itself throws?If the upstream stage completed normally, the new exception from the action is recorded as the stage's failure. If the upstream stage already failed, the original exception is kept and the action's exception is suppressed.
- Why might you prefer handle over exceptionally?handle sees both outcomes in one place, so you can transform the success value and recover from errors together; it also lets you change the result type (U), whereas exceptionally must return the same type and only fires on error.
saying these in an interview costs you the question
- Thinking whenComplete recovers from the failure — it does not; the exception still propagates
- Believing exceptionally runs on success too (it only runs on failure)
- Forgetting that handle and whenComplete run on success as well, not just on errors
- Assuming both the value and throwable can be non-null at once in handle/whenComplete