Question 19 · Section 19

What happens if an exception occurs in CompletableFuture chain

An exception in a CompletableFuture chain interrupts execution and propagates downstream — all subsequent stages (thenApply, thenAccept, thenRun) are not executed.

Language versions: English Russian Ukrainian

🟢 Junior Level

An exception in a CompletableFuture chain interrupts execution and propagates downstream — all subsequent stages (thenApply, thenAccept, thenRun) are not executed.

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> {
        throw new RuntimeException("Error!");
    })
    .thenApply(s -> s.toUpperCase());  // Will not execute

// future.isCompletedExceptionally() = true
// future.join() — throws CompletionException

Simple analogy:

  • CF chain is like an assembly line
  • If one stage errors out — the whole line stops
  • You need an “emergency exit” (exceptionally/handle)

Exception handling

// 1. exceptionally — fallback value
CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> { throw new RuntimeException("Error!"); })
    .exceptionally(ex -> "Fallback value");

// 2. handle — handles both result and exception
CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> { throw new RuntimeException("Error!"); })
    .handle((result, ex) -> ex != null ? "Fallback" : result);

// 3. whenComplete — observation only (does not catch!)
CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> "Hello")
    .whenComplete((result, ex) -> {
        if (ex != null) {
            log.error("Error", ex);
        }
    });
    // Exception is still in CF — need exceptionally/handle

🟡 Middle Level

CompletionException

join() and get() wrap the original exception in a CompletionException. The original exception is accessible via getCause(). exceptionally receives a CompletionException with the original exception as cause.

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> { throw new RuntimeException("Original error"); });

try {
    future.join();  // throws CompletionException
} catch (CompletionException e) {
    Throwable cause = e.getCause();  // RuntimeException: "Original error"
}

Exception propagation

supplyAsync(throws) → thenApply(skips) → thenAccept(skips) → exceptionally(catches)

The exception travels through the entire chain until the first handler

exceptionally vs handle vs whenComplete

Method Catches Returns value Executes on success
exceptionally Yes Yes (fallback) No
handle Yes Yes Yes
whenComplete No (observation only) No (passes through) Yes
// whenComplete does NOT catch — exception stays in CF
cf.whenComplete((r, ex) -> log("done"))
  .join();  // still throws!

// handle catches — CF completes normally
cf.handle((r, ex) -> ex != null ? "fallback" : r)
  .join();  // OK

🔴 Senior Level

Internal Implementation

The exception is stored in the result field as an AltResult (internal class) which wraps the throwable. When join()/get() is called, it is unwrapped into a CompletionException.

// Simplified:
private volatile Object result;  // can be AltResult with Throwable

// completeExceptionally(Throwable ex)
// — sets result = new AltResult(ex)
// — notifies all dependent CFs

Exception composition

// Multiple CFs with error handling
CompletableFuture<String> cf1 = service1()
    .exceptionally(ex -> "fallback1");
CompletableFuture<String> cf2 = service2()
    .exceptionally(ex -> "fallback2");

// allOf will only fail if both failed
// and didn't handle errors
CompletableFuture.allOf(cf1, cf2).join();

Best Practices

// ✅ Always add exceptionally/handle
cf.exceptionally(ex -> { log.error("...", ex); return defaultValue; });

// ✅ Check cause in CompletionException
catch (CompletionException e) { handleCause(e.getCause()); }

// ✅ whenComplete only for side-effects (logging, metrics)
cf.whenComplete((r, ex) -> metrics.record(ex != null ? "error" : "ok"));

// ❌ Don't ignore exceptions in whenComplete
// ❌ Don't forget a handler at the end of the chain
// ❌ Don't wrap exceptions without preserving cause

🎯 Interview Cheat Sheet

Must know:

  • Exception breaks the chain — all downstream thenApply/thenAccept/thenRun are not executed
  • join()/get() wrap in CompletionException, original via getCause()
  • exceptionally catches and returns fallback, handle — full control, whenComplete — only observes
  • Exception is stored as AltResult (internal class), does not block CF
  • Handler at the end of the chain — mandatory for production

Common follow-up questions:

  • Does whenComplete catch exceptions? — No, only observes. CF remains in error state
  • Does exceptionally receive the original exception? — No, CompletionException. Use getCause() for the original
  • What happens to allOf if one CF fails? — allOf completes exceptionally. Need handle on each CF
  • Exception in exceptionally — what happens? — New exception, caught by downstream exceptionally

Red flags (DO NOT say):

  • “whenComplete catches exceptions” — only observes, error remains in CF
  • “exceptionally receives the original exception” — receives CompletionException
  • “Exception blocks CF forever” — CF completes exceptionally, does not block

Related topics:

  • [[6. How to handle exceptions in CompletableFuture chain]]
  • [[7. What is the difference between handle(), exceptionally() and whenComplete()]]
  • [[27. How to implement retry logic with CompletableFuture]]
  • [[21. How to implement timeout for CompletableFuture]]