⏱️ Section 19 · Question #7

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:

  1. exceptionally(Function<Throwable, T> fallback) (analogous to catch):
    • Invoked only when an exception occurs.
    • Catches the error and returns a fallback value of the same type T.
    • If the previous stage succeeds, exceptionally is skipped entirely.
  2. 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 T to U, recover from an error, or throw a new exception.
  3. whenComplete(BiConsumer<T, Throwable> action) (analogous to finally / 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, while result contains the computed value (which itself can legitimately be null!).
  • On failure: ex != null, while result == 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:

  • UniExceptionally
  • UniHandle
  • UniWhenComplete

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");
});
  1. The returned future monitored completes exceptionally with $E_2$ (IllegalStateException) as the primary cause.
  2. The original exception $E_1$ (IOException) is preserved via E2.addSuppressed(E1).
  3. If the antecedent stage succeeded, but whenComplete throws an exception, the returned stage fails with the exception from whenComplete.

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 == null because the antecedent stage completed successfully without throwing an error.
  • result == null because Void has only one representation in Java: null. This is why checking ex != null is the only safe way to distinguish success from failure in handle and whenComplete.

3. Why is whenComplete strongly preferred over handle for telemetry, auditing, and logging in production systems?

Answer:

  1. Type Signature Invariant: whenComplete guarantees that neither the payload type nor the returned value can be mutated by logging or metrics code.
  2. Prevention of Accidental Error Swallowing: By default, handle swallows exceptions unless the developer explicitly re-throws throw new CompletionException(ex). Returning null or a default value from handle silently converts critical system outages into successful completions. whenComplete cannot 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, or exceptionallyAsync (Java 12+) with a dedicated Executor.

🎯 Interview Cheat Sheet

Summary in 60 Seconds

  • exceptionally: Runs only on error; returns a fallback value of the same type $T$; acts like catch.
  • handle: Runs always; accepts (result, ex) and can return a new type $U$; acts like try-catch-finally with 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 via addSuppressed().
  • Use exceptionallyCompose (Java 12+) for asynchronous fallback stages.

Common Interview Mistakes

  • Expecting whenComplete to swallow exceptions or return fallback defaults.
  • Checking if (result == null) instead of if (ex != null) inside handle.
  • Attempting to change the stage return type using exceptionally.