skip to content

If the Supplier passed to CompletableFuture.supplyAsync throws an exception, what happens to the future and to a later get() call?

level: seniorimportance: should knowfreq 55%

answer

  1. exception captured → future completes exceptionally (not propagated out of supplyAsync)
  2. get() → ExecutionException (checked); join() → CompletionException (unchecked)
  3. getCause() unwraps the original throwable
  4. exceptionally/handle recover; whenComplete only peeks
  5. no handler + no get/join = silently swallowed

basics

~10 s

The exception is captured and the future completes exceptionally—it does not propagate out of supplyAsync. A later get() then throws an ExecutionException whose cause is the thrown exception; join() throws a CompletionException wrapping it.

solid answer

~40 s

supplyAsync runs the Supplier on a background thread, so an exception it throws can't propagate to the caller of supplyAsync—that call already returned the future. Instead the framework catches the throwable and completes the future *exceptionally* with it. Nothing is logged or rethrown automatically; the failure is latent until you observe it. How you observe it differs by accessor: get() throws a checked ExecutionException wrapping the original cause; join() throws an unchecked CompletionException wrapping it. Downstream stages skip their normal body and propagate the failure, except recovery stages—exceptionally, handle, whenComplete—which receive the throwable. A common production bug is creating such a future and never chaining a handler or calling get/join, so the exception is swallowed silently. Always attach an exceptionally/handle or otherwise surface failures.

go deeper

for a junior

Knows that a throwing supplier doesn't crash the caller; the failure shows up later when reading the result.

for a middle

States the future completes exceptionally and get() throws ExecutionException wrapping the cause.

for a senior

Distinguishes get() vs join() wrappers, explains propagation through stages and recovery via exceptionally/handle, and warns about silent swallowing.

for a principal

Establishes codebase-wide error-handling conventions for async pipelines (mandatory terminal handlers, logging, mapping to domain errors) and observability for swallowed failures.

## The setup ```java CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> { throw new IllegalStateException("boom"); }); ``` The lambda is a `Supplier<Integer>`. It runs *not now, on this thread*, but later on a worker thread of the executor (the common pool by default). So when does the exception surface, and to whom? ## Step 1: the exception is captured, not propagated The call `CompletableFuture.supplyAsync(...)` returns a future **immediately**, before the supplier has likely even run. Therefore the supplier's exception *cannot* be thrown out of `supplyAsync`—there is nobody on that stack to catch it. Instead, the framework wraps the supplier's execution in a try/catch. When the supplier throws, the future is **completed exceptionally**: it transitions to a done-but-failed state holding that throwable. `cf.isCompletedExceptionally()` becomes `true`. Crucially, **nothing is printed or logged**. The failure is now latent data inside the future. ## Step 2: how the failure surfaces when you observe it The wrapping exception type depends on *how* you read the result: | Accessor | Throws | Checked? | Wrapper | |---|---|---|---| | `get()` | `ExecutionException` | checked | `getCause()` = original | | `get(timeout)` | `ExecutionException` (or `TimeoutException`) | checked | as above | | `join()` | `CompletionException` | unchecked | `getCause()` = original | | `getNow(fallback)` | `CompletionException` | unchecked | as above | So: ```java try { cf.get(); } catch (ExecutionException e) { Throwable cause = e.getCause(); // the IllegalStateException("boom") } ``` With `join()` you'd catch a `CompletionException` instead (no checked-exception boilerplate—handy inside lambdas). ## Step 3: how the failure flows through a chain Given `cf.thenApply(x -> x + 1).thenApply(x -> x * 2)`: - The failed `cf` causes both `thenApply` stages to be **skipped**; their functions never run. The failure propagates straight down. - **Recovery stages** intercept it: - `exceptionally(ex -> fallback)` — supplies a fallback value, turning failure back into success. - `handle((value, ex) -> ...)` — always runs, sees either the value or the throwable. - `whenComplete((value, ex) -> ...)` — a side-effecting peek; it does *not* swallow the exception (the failure continues downstream). ```java cf.exceptionally(ex -> -1) // recover to -1 .thenAccept(System.out::println); // prints -1 ``` ## The silent-swallow trap The most dangerous part: if you create a failing future and **never** call `get`/`join` and **never** attach a handler, the exception is *completely invisible*. Unlike a thread's uncaught exception (which hits the thread's uncaught-exception handler), a CompletableFuture's captured exception sits silently in the object. Forgetting to observe it is a real source of "the work just didn't happen and nothing told me" bugs. **Rules of thumb:** 1. Always terminate a chain with `exceptionally`/`handle`, or 2. Always `join()`/`get()` and handle the wrapper, or 3. At minimum log via `whenComplete((v, ex) -> { if (ex != null) log.error(...); })`. ## Why two different wrapper types? `get()` predates CompletableFuture (it's from the `Future` interface, Java 5) and is contractually `ExecutionException` (checked). `join()` is CompletableFuture's own convenience method that throws the unchecked `CompletionException`, so it composes cleanly inside lambdas and streams where checked exceptions are awkward. Both wrap the same underlying cause.

  • What is the difference between get() and join() when the future failed?
    get() throws a checked ExecutionException; join() throws an unchecked CompletionException. Both wrap the original cause, retrievable via getCause(). join() is convenient inside lambdas because it needs no checked-exception handling.
  • How do you recover from a failed CompletableFuture without throwing?
    Attach exceptionally(ex -> fallbackValue) to substitute a value, or handle((value, ex) -> ...) to inspect both and return a result. whenComplete only peeks and does not suppress the failure.

saying these in an interview costs you the question

  • Thinking supplyAsync rethrows the supplier's exception synchronously to its caller
  • Confusing get()'s ExecutionException with join()'s CompletionException
  • Assuming the exception is auto-logged like a thread's uncaught exception
  • Believing thenApply still runs its function after an upstream failure

context