What is the difference between handle(), exceptionally() and whenComplete()
Three methods for error handling in CompletableFuture:
🟢 Junior Level
Three methods for error handling in CompletableFuture:
exceptionally()— return a fallback on error (likecatch)handle()— handle both success and error (liketry/catchwith return)whenComplete()— perform an action on completion without changing the result (likefinally)
// 1. exceptionally — fallback on error
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("Error");
}).exceptionally(ex -> "default value"); // will return "default value"
// 2. handle — full control
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("Error");
}).handle((result, ex) -> {
if (ex != null) return "error occurred";
return result;
});
// 3. whenComplete — observation, does not change the result
CompletableFuture.supplyAsync(() -> "Hello")
.whenComplete((result, ex) -> {
if (ex != null) {
System.err.println("Error: " + ex);
} else {
System.out.println("Result: " + result);
}
});
🟡 Middle Level
Detailed comparison
exceptionally — only on error:
fetchDataAsync()
.exceptionally(ex -> {
log.error("Fetch failed", ex);
return "default"; // fallback value
});
handle — always called:
fetchDataAsync()
.handle((result, ex) -> {
if (ex != null) {
log.error("Failed", ex);
return "error";
}
return result.toUpperCase(); // can modify the result
});
whenComplete — side effect, does not change the result:
fetchDataAsync()
.whenComplete((result, ex) -> {
// Does not change the result — only side effect
if (ex == null) {
metrics.increment("success");
} else {
metrics.increment("failure");
}
})
.thenApply(s -> s.toUpperCase()); // result is unchanged
Comparison table
| Method | Can change result? | Can change type? | Absorbs error? | When called? |
|---|---|---|---|---|
| exceptionally | ✅ Yes | ❌ No | ✅ Yes | Only on error |
| whenComplete | ❌ No | ❌ No | ❌ No | Always |
| handle | ✅ Yes | ✅ Yes | ✅ Yes | Always |
whenComplete: important behavior
If the action itself throws an exception — the NEW exception will replace the original as the stage’s result. The original will be lost (not added as suppressed).
cf.whenComplete((result, ex) -> {
throw new RuntimeException("New error"); // will replace the original ex!
});
// Original exception is lost!
Typical mistakes
1. Null handling in handle:
cf.handle((result, ex) -> {
// On success: ex == null, result != null
// On error: ex != null, result == null
// Null check is mandatory!
if (ex != null) return fallback;
return result.toUpperCase();
});
2. exceptionally is not called on success:
cf.exceptionally(ex -> "fallback")
.thenAccept(s -> System.out.println(s));
// If cf is successful — exceptionally is skipped, result goes further
🔴 Senior Level
exceptionallyCompose (Java 12+)
This is the “flatMap” for error handling. Regular exceptionally forces you to return a ready-made object, while exceptionallyCompose allows you to return another CompletableFuture.
// "If the primary microservice is down, go to the backup asynchronously"
cf.exceptionallyCompose(ex -> {
if (isRetryable(ex)) {
return retryOperation(); // CompletableFuture<T>
}
return CompletableFuture.failedFuture(ex);
});
Thread Context
All three methods execute in the same thread that completed the previous stage (unless you use the *Async versions). This can lead to blocking “foreign” pools.
// Dangerous: executes in the thread that completed the CF
cf.whenComplete((r, ex) -> heavyOperation());
// Safe: switches thread
cf.whenCompleteAsync((r, ex) -> heavyOperation(), executor);
When NOT to use handle
- When you only need error handling — use
exceptionally(clearer intent) - When you need a side effect without changing the result —
whenComplete
// ❌ handle when exceptionally is enough
cf.handle((r, ex) -> ex != null ? defaultValue : r); // excessive
// ✅ exceptionally — clearer intent
cf.exceptionally(ex -> defaultValue);
// ❌ handle when you only need a side effect
cf.handle((r, ex) -> { log.info("done"); return r; }); // excessive
// ✅ whenComplete — does not change the result
cf.whenComplete((r, ex) -> log.info("done"));
Short-circuit
If an exceptionally in the middle of a long chain returns a result, all subsequent thenApply calls will work with this result as if no error occurred.
cf.thenApply(s -> { throw new RuntimeException("Error"); })
.exceptionally(ex -> "fallback") // returned "fallback"
.thenApply(s -> s.toUpperCase()); // works with "fallback"!
Best Practices
// ✅ exceptionally for fallback
cf.exceptionally(ex -> defaultValue);
// ✅ handle for full control
cf.handle((result, ex) -> ex != null ? fallback : transform(result));
// ✅ whenComplete for monitoring
cf.whenComplete((r, ex) -> metrics.record(r, ex));
// ✅ exceptionallyCompose for retries (Java 12+)
cf.exceptionallyCompose(ex -> isRetryable(ex) ? retry() : failedFuture(ex));
// ❌ Ignoring errors
// ❌ Blocking operations in handlers
// ❌ handle when exceptionally is enough
🎯 Interview Cheat Sheet
Must know:
- exceptionally — only on error, returns fallback, type does not change
- handle — always called, can change type, absorbs error
- whenComplete — always called, does NOT change the result, does NOT absorb error
- handle = full control, exceptionally = clean fallback, whenComplete = monitoring
- exceptionallyCompose (Java 12+) — async retry, returns another CF
Frequent follow-up questions:
- What happens if whenComplete throws? — The new exception replaces the original, the original is lost
- Can exceptionally change the type? — No, returns the same T. handle can change the type
- When to use exceptionallyCompose? — When an error requires launching another async operation (retry, fallback service)
- handle vs exceptionally — when which? — handle when you also need result processing, exceptionally only for errors
Red flags (DO NOT say):
- “whenComplete intercepts errors” — it only observes, the error remains in the CF
- “handle and exceptionally are the same” — handle is always called, exceptionally only on error
- “exceptionally can return a different type” — no, only T -> T
Related topics:
- [[6. How to handle exceptions in CompletableFuture chain]]
- [[18. What happens if an exception occurs in CompletableFuture chain]]
- [[27. How to implement retry logic with CompletableFuture]]
- [[20. How to implement timeout for CompletableFuture]]