What is the Difference Between handle(), exceptionally() and whenComplete()
All three methods — handle(), exceptionally(), and whenComplete() — are used to react to stage completion and manage exceptions in a CompletableFuture pipeline. However, they se...
🟢 Junior Level
All three methods — handle(), exceptionally(), and whenComplete() — are used to react to stage completion and manage exceptions in a CompletableFuture pipeline. However, they serve distinctly different purposes, accept different functional interfaces, and impact pipeline return types differently:
exceptionally(Function<Throwable, T> fallback)(analogous tocatch):- Invoked only when an exception occurs.
- Catches the error and returns a fallback value of the same type
T. - If the previous stage succeeds,
exceptionallyis skipped entirely.
handle(BiFunction<T, Throwable, U> fn)(universal handler / transformer):- Invoked always (on both success and failure).
- Takes two arguments: the computation result and the exception.
- Can transform the return type from
TtoU, recover from an error, or throw a new exception.
whenComplete(BiConsumer<T, Throwable> action)(analogous tofinally/ spy observer):- Invoked always (on both success and failure).
- Intended purely for side effects (logging, metrics telemetry, closing resource handles).
- Cannot alter the return value or swallow exceptions: passes the antecedent value or exception downstream unmodified.
Code Comparison
CompletableFuture<String> source = fetchDataAsync();
// 1. exceptionally: Called ONLY on error; type String remains unchanged
source.exceptionally(ex -> "default_fallback");
// 2. handle: ALWAYS called; can transform String into Integer
source.handle((val, ex) -> ex != null ? -1 : val.length());
// 3. whenComplete: ALWAYS called; returns void, preserves original String or error
source.whenComplete((val, ex) -> {
if (ex != null) log.error("Failed", ex);
else log.info("Success: {}", val);
});
🟡 Middle Level
Feature Comparison Matrix
| Criterion | exceptionally |
handle |
whenComplete |
|---|---|---|---|
| Functional Interface | Function<Throwable, ? extends T> |
BiFunction<? super T, Throwable, ? extends U> |
BiConsumer<? super T, ? super Throwable> |
| Execution Trigger | Errors only | Unconditional (success & error) | Unconditional (success & error) |
| Can Modify Value? | Yes (returns fallback) | Yes (returns function result) | No (passes value through) |
| Can Change Type ($T \to U$)? | No (must return $T$) | Yes (returns CompletableFuture<U>) |
No (retains CompletableFuture<T>) |
| Swallows Exceptions? | Yes (if returns value) | Yes (if returns value) | No (error propagates downstream) |
| Primary Use Case | Fallback recovery | Result transformation / Recovery | Logging, metrics, resource cleanup |
Critical Trap: Null Checking in handle
In handle((result, ex) -> ...), both arguments are provided, but their values reflect the outcome:
- On success:
ex == null, whileresultcontains the computed value (which itself can legitimately benull!). - On failure:
ex != null, whileresult == null.
// DANGEROUS: Assuming result == null implies an error
cf.handle((result, ex) -> {
if (result == null) { // FALSE POSITIVE: Task may have legitimately returned null!
return fallback;
}
return result;
});
// CORRECT: ALWAYS verify ex != null to detect failures
cf.handle((result, ex) -> {
if (ex != null) {
log.warn("Operation failed: {}", ex.getMessage());
return fallback;
}
return result;
});
🔴 Senior Level
Internal Architecture and Completion Nodes
Each method maps directly to a dedicated UniCompletion node in the lock-free Treiber stack:
UniExceptionallyUniHandleUniWhenComplete
Exception Collision in whenComplete
If the antecedent future failed with exception $E_1$, and the action inside whenComplete((r, ex) -> { throw new E2(); }) also throws exception $E_2$:
CompletableFuture<String> failed = CompletableFuture.failedFuture(new IOException("E1: Network down"));
CompletableFuture<String> monitored = failed.whenComplete((r, ex) -> {
throw new IllegalStateException("E2: Metric service failed");
});
- The returned future
monitoredcompletes exceptionally with $E_2$ (IllegalStateException) as the primary cause. - The original exception $E_1$ (
IOException) is preserved viaE2.addSuppressed(E1). - If the antecedent stage succeeded, but
whenCompletethrows an exception, the returned stage fails with the exception fromwhenComplete.
Asynchronous Error Recovery: exceptionallyCompose (Java 12+)
Because exceptionally accepts a synchronous Function, recovering via a secondary asynchronous call (e.g., remote backup replica) previously resulted in nested CompletableFuture<CompletableFuture<T>>.
Java 12 introduced exceptionallyCompose:
primaryCluster.queryAsync(data)
.exceptionallyCompose(ex -> {
log.error("Primary cluster failed, failing over to secondary...", ex);
return secondaryCluster.queryAsync(data); // Returns CompletableFuture<Result>
});
4 Tricky Questions
1. Can .exceptionally() change the return type from CompletableFuture<String> to CompletableFuture<Integer>? How can this be achieved?
Answer:
No, exceptionally() cannot change the return type.
Its method signature is exceptionally(Function<Throwable, ? extends T> fn), requiring a return value of type $T$ or a subtype.
If error recovery requires changing the pipeline’s return type (e.g., returning an HTTP status code integer or a custom error DTO), you must use handle(), which accepts BiFunction<? super T, Throwable, ? extends U> and returns CompletableFuture<U>.
2. What arguments are passed to .handle((result, ex) -> ...) if the antecedent CompletableFuture<Void> completes successfully?
Answer:
Both arguments — result and ex — will be null.
ex == nullbecause the antecedent stage completed successfully without throwing an error.result == nullbecauseVoidhas only one representation in Java:null. This is why checkingex != nullis the only safe way to distinguish success from failure inhandleandwhenComplete.
3. Why is whenComplete strongly preferred over handle for telemetry, auditing, and logging in production systems?
Answer:
- Type Signature Invariant:
whenCompleteguarantees that neither the payload type nor the returned value can be mutated by logging or metrics code. - Prevention of Accidental Error Swallowing: By default,
handleswallows exceptions unless the developer explicitly re-throwsthrow new CompletionException(ex). Returningnullor a default value fromhandlesilently converts critical system outages into successful completions.whenCompletecannot swallow errors; exceptions automatically pass through to downstream handlers.
4. In which thread do the callbacks registered in handle, exceptionally, and whenComplete execute?
Answer:
Consistent with all non-async CompletionStage methods:
- If the antecedent stage has already completed when the callback is attached, it executes synchronously on the caller thread.
- If the antecedent stage is still pending, it executes on whichever worker thread completes the antecedent stage.
To avoid blocking caller threads or shared worker pools during heavy logging or recovery operations, explicitly use their asynchronous counterparts:
handleAsync,whenCompleteAsync, orexceptionallyAsync(Java 12+) with a dedicatedExecutor.
🎯 Interview Cheat Sheet
Summary in 60 Seconds
exceptionally: Runs only on error; returns a fallback value of the same type $T$; acts likecatch.handle: Runs always; accepts(result, ex)and can return a new type $U$; acts liketry-catch-finallywith return.whenComplete: Runs always; accepts(result, ex)for side effects; does not change return value or swallow exceptions.- In
whenComplete, an exception in the consumer replaces the root cause, but attaches the original error viaaddSuppressed(). - Use
exceptionallyCompose(Java 12+) for asynchronous fallback stages.
Common Interview Mistakes
- Expecting
whenCompleteto swallow exceptions or return fallback defaults. - Checking
if (result == null)instead ofif (ex != null)insidehandle. - Attempting to change the stage return type using
exceptionally.