Question 7 · Section 19

What is the difference between handle(), exceptionally() and whenComplete()

Three methods for error handling in CompletableFuture:

Language versions: English Russian Ukrainian

🟢 Junior Level

Three methods for error handling in CompletableFuture:

  • exceptionally() — return a fallback on error (like catch)
  • handle() — handle both success and error (like try/catch with return)
  • whenComplete() — perform an action on completion without changing the result (like finally)
// 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]]