How to handle exceptions in CompletableFuture chain
There are 3 main ways to handle exceptions in CompletableFuture:
🟢 Junior Level
There are 3 main ways to handle exceptions in CompletableFuture:
exceptionally()— return a default value on errorhandle()— handle both result and errorwhenComplete()— perform an action on completion (does not change the result)
// 1. exceptionally — fallback value
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("Error");
}).exceptionally(ex -> "default value"); // will return "default value"
// 2. handle — handles both success and error
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("Error");
}).handle((result, ex) -> {
if (ex != null) return "error occurred";
return result;
});
// 3. whenComplete — just learn about completion
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 — like try-catch:
CompletableFuture<String> cf = fetchDataAsync()
.exceptionally(ex -> {
log.error("Fetch failed", ex);
return "default"; // fallback value
});
handle — full control:
CompletableFuture<String> cf = fetchDataAsync()
.handle((result, ex) -> {
if (ex != null) {
log.error("Failed", ex);
return "error";
}
return result.toUpperCase(); // can modify the result
});
whenComplete — side effect:
CompletableFuture<String> cf = 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
Typical mistakes
- exceptionally CATCHES exceptions from thenApply: ```java // ✅ Exception in thenApply will be caught by downstream exceptionally cf.thenApply(s -> { throw new RuntimeException(“Error”); }).exceptionally(ex -> “default”); // ✅ OK, catches it
// ✅ But handle is better for full control cf.handle((result, ex) -> { if (ex != null) return “default”; return result; });
2. **CompletionException wrapping:**
```java
// Exceptions are wrapped in CompletionException
cf.exceptionally(ex -> {
// ex — CompletionException, not the original exception!
Throwable cause = ex.getCause(); // original
return "default";
});
🔴 Senior Level
Internal Implementation
exceptionally:
public CompletableFuture<T> exceptionally(Function<Throwable, ? extends T> fn) {
return uniExceptioniallyStage(fn);
}
// Called only on error
// If success — fn is not called
// Returns a fallback value
handle:
public <U> CompletableFuture<U> handle(BiFunction<? super T, Throwable, ? extends U> fn) {
return uniHandleStage(null, fn);
}
// Called ALWAYS — both on success and error
// result — value (null on error)
// ex — exception (null on success)
whenComplete:
public CompletableFuture<T> whenComplete(BiConsumer<? super T, ? super Throwable> action) {
return uniWhenCompleteStage(null, action);
}
// Called ALWAYS
// Does NOT change the result — just a side effect
// Exception from action is wrapped
Architectural Trade-offs
| Method | Always called | Changes result | Catches errors |
|---|---|---|---|
| exceptionally | ❌ Only on error | ✅ Fallback | ✅ |
| handle | ✅ | ✅ Full control | ✅ |
| whenComplete | ✅ | ❌ Side effect only | ❌ |
Edge Cases
1. Multiple error handlers:
cf.exceptionally(ex -> {
log.error("First handler", ex);
return "first";
}).exceptionally(ex -> {
// Not called — the first one already handled
return "second";
});
2. Exception in handler:
cf.exceptionally(ex -> {
throw new RuntimeException("Handler error"); // New exception
}).exceptionally(ex -> {
// Will catch the exception from the first handler
return "recovered";
});
3. handle for transformation:
// handle can change the type
cf.<String>handle((result, ex) -> {
if (ex != null) {
return "Error: " + ex.getMessage();
}
return "Success: " + result;
});
Performance
exceptionally: ~5 ns (only on error)
handle: ~10 ns (always)
whenComplete: ~8 ns (always)
Overhead is negligible compared to async operations
Production Experience
Retry logic:
public <T> CompletableFuture<T> withRetry(
Supplier<CompletableFuture<T>> operation,
int maxRetries
) {
return operation.get()
.exceptionallyCompose(ex -> {
if (maxRetries > 0 && isRetryable(ex)) {
log.warn("Retry after error", ex);
// In production, add a delay:
return CompletableFuture.delayedExecutor(100, MILLISECONDS)
.thenCompose(v -> withRetry(operation, maxRetries - 1));
}
return CompletableFuture.failedFuture(ex);
});
}
// Java 12+
cf.exceptionallyCompose(ex -> {
if (shouldRetry(ex)) {
return retryOperation();
}
return CompletableFuture.failedFuture(ex);
});
Circuit breaker pattern:
public CompletableFuture<Response> callWithCircuitBreaker(Request req) {
if (circuitBreaker.isOpen()) {
return CompletableFuture.failedFuture(
new CircuitBreakerOpenException());
}
return callAsync(req)
.handle((result, ex) -> {
if (ex != null) {
circuitBreaker.recordFailure();
throw new CompletionException(ex);
}
circuitBreaker.recordSuccess();
return result;
});
}
Best Practices
// ✅ exceptionally for fallback
cf.exceptionally(ex -> defaultValue);
// ✅ handle for full control
cf.handle((result, ex) -> ex != null ? fallback : result);
// ✅ whenComplete for monitoring
cf.whenComplete((r, ex) -> metrics.record(r, ex));
// ❌ Ignoring errors
// ❌ Blocking operations in handlers
// ❌ Creating new exceptions without reason
🎯 Interview Cheat Sheet
Must know:
- exceptionally(Function<Throwable, T>) — fallback on error, like catch
- handle(BiFunction<T, Throwable, U>) — always called, full control
- whenComplete(BiConsumer<T, Throwable>) — side effect only, does not change the result
- Exceptions are wrapped in CompletionException, original via getCause()
- exceptionallyCompose (Java 12+) — returns another CF on error (for retry)
Frequent follow-up questions:
- Does whenComplete catch exceptions? — No, it only observes. The exception remains in the CF
- Is exceptionally called on success? — No, only on error
- How to do retry? — exceptionallyCompose (Java 12+) or recursion in exceptionally
- Can you handle an error in multiple handlers? — Yes, but the first one that catches it will consume it
Red flags (DO NOT say):
- “whenComplete catches exceptions” — it only observes, does not intercept
- “exceptionally is always called” — only on error, handle is always called
- “Exception in handler won’t be handled” — handler exceptions are also caught by downstream exceptionally
Related topics:
- [[7. What is the difference between handle(), exceptionally() and whenComplete()]]
- [[18. What happens if an exception occurs in CompletableFuture chain]]
- [[27. How to implement retry logic with CompletableFuture]]
- [[1. What is CompletableFuture and how does it differ from Future]]