skip to content

When an exception is thrown inside a CompletableFuture stage, how does it propagate down the chain? What is CompletionException and where does it come from?

level: middleimportance: must knowfreq 60%

answer

  1. Throw -> stage completes exceptionally -> normal stages skipped
  2. Dependent stages wrap cause in CompletionException
  3. Originating stage holds the raw exception
  4. Always unwrap via getCause() in handlers
  5. CompletionException is unchecked

basics

~10 s

If a stage throws, the future completes exceptionally and later stages are skipped until an error-handling callback. The original exception is usually wrapped in a CompletionException, so you often unwrap it via getCause().

solid answer

~40 s

When the function inside a stage throws, that stage completes exceptionally with the thrown Throwable. Downstream dependent stages (thenApply, thenCompose, etc.) are then skipped and the failure short-circuits to the first error-aware callback (exceptionally/handle/whenComplete). The key subtlety: as the failure flows to *dependent* stages, the runtime wraps the original cause in an unchecked CompletionException. So inside an exceptionally on a downstream stage, the Throwable you receive may be a CompletionException whose getCause() is your real exception. The stage that *originally* threw sees the raw exception; dependents see it wrapped. This wrapping is why robust error handlers unwrap: if (ex instanceof CompletionException) ex = ex.getCause(). It mirrors how join() throws CompletionException — the wrapping is consistent across the propagation and the blocking accessor.

code

java · 8 lines
java
CompletableFuture.supplyAsync(() -> { throw new IllegalArgumentException("bad"); })
    .thenApply(v -> v + "!")          // skipped on failure
    .exceptionally(ex -> {
        Throwable cause = (ex instanceof CompletionException)
            ? ex.getCause() : ex;       // unwrap before inspecting
        System.out.println(cause.getClass()); // IllegalArgumentException
        return "fallback";
    });

go deeper

for a junior

Knows that a thrown exception makes the future 'fail' and that later steps don't run; may not mention wrapping.

for a middle

Explains short-circuit propagation past normal stages and that the cause is wrapped in CompletionException, so handlers unwrap via getCause().

for a senior

Distinguishes the raw exception at the originating stage from the wrapped one downstream, knows the no-double-wrap rule, and writes unwrap-normalizing handlers.

for a principal

Designs a consistent exception-normalization layer (helper to unwrap), reasons about cancellation vs failure surfacing, and standardizes error semantics across an async codebase.

## The model A `CompletableFuture` stage holds a function. When you run that function and it throws, the stage doesn't crash the program — it captures the `Throwable` and becomes **exceptionally completed**. The whole point of `CompletableFuture` is composition, so the interesting question is: what happens to the *chain* of stages built on top of a failing one? ## Short-circuit propagation Dependent stages created with `thenApply`, `thenAccept`, `thenCompose`, `thenCombine`, etc. only run on **normal** completion of their input. If the input completed exceptionally, these stages are **skipped** and *also* complete exceptionally with the same failure. The error therefore "short-circuits" past all the normal-path stages until it reaches an error-aware stage: `exceptionally`, `handle`, or `whenComplete`. This is analogous to an exception unwinding past statements until a `catch`. ```java cf.thenApply(this::step1) // skipped if cf failed .thenApply(this::step2) // skipped .exceptionally(ex -> { // <-- first to actually run on failure // ex may be a CompletionException wrapping the real cause return fallback(); }); ``` ## What is CompletionException? `CompletionException` is an **unchecked** (`RuntimeException`) wrapper introduced for `CompletableFuture`. When a failure propagates to a **dependent** stage, the framework wraps the original cause in a `CompletionException` (unless the cause is *already* a `CompletionException`, in which case it isn't double-wrapped). So: - The stage whose function **directly threw** completes with the **raw** exception (e.g. `IllegalStateException`). - Stages **downstream** of it that inherit the failure see it wrapped: `CompletionException(cause = IllegalStateException)`. Why wrap at all? Because `CompletableFuture` callbacks are functional (they can't declare `throws SomeCheckedException`), the API needs a single unchecked carrier to move *any* throwable — including checked ones — through the lambda-based pipeline. `CompletionException` is that carrier for the *dependency/propagation* path (and for `join()`); `ExecutionException` plays the analogous role for `get()`. ## Practical consequence: always unwrap Because the wrapping depends on whether you're at the originating stage or a dependent one, robust handlers normalize: ```java .exceptionally(ex -> { Throwable cause = (ex instanceof CompletionException && ex.getCause() != null) ? ex.getCause() : ex; if (cause instanceof TimeoutException) { ... } return fallback(); }); ``` If you skip the unwrap, an `instanceof` check against your domain exception may silently fail because you're actually holding a `CompletionException`. ## Edge cases - If the thrown exception is already a `CompletionException`, it is **not** re-wrapped. - `whenComplete` and `handle` receive the same (possibly wrapped) throwable that propagated to them. - Cancellation surfaces as `CancellationException` (a `CancellationException`, not wrapped in `CompletionException` for `join()` — it's thrown directly). ## Mental model Think of the failure as a baton: the runner who *drops* it (the throwing stage) holds the raw exception; every runner it's *handed to* afterward receives it inside a `CompletionException` envelope. Unwrap the envelope before inspecting the baton.

  • Does CompletableFuture ever double-wrap into CompletionException(CompletionException(...))?
    No. If the propagating cause is already a CompletionException, it is not wrapped again; the existing CompletionException is reused.
  • Why must this wrapper be unchecked?
    CompletableFuture's callbacks are functional interfaces that cannot declare checked exceptions, so any failure (including checked ones) must be carried by an unchecked RuntimeException — CompletionException.

saying these in an interview costs you the question

  • Doing instanceof on the domain exception without unwrapping CompletionException first
  • Believing the original throwing stage also wraps the exception (it holds the raw one)
  • Thinking downstream thenApply stages still run after a failure
  • Confusing CompletionException (propagation/join) with ExecutionException (get)

context