If the Supplier passed to CompletableFuture.supplyAsync throws an exception, what happens to the future and to a later get() call?
answer
- exception captured → future completes exceptionally (not propagated out of supplyAsync)
- get() → ExecutionException (checked); join() → CompletionException (unchecked)
- getCause() unwraps the original throwable
- exceptionally/handle recover; whenComplete only peeks
- no handler + no get/join = silently swallowed
basics
~10 sThe 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 ssupplyAsync 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
Knows that a throwing supplier doesn't crash the caller; the failure shows up later when reading the result.
States the future completes exceptionally and get() throws ExecutionException wrapping the cause.
Distinguishes get() vs join() wrappers, explains propagation through stages and recovery via exceptionally/handle, and warns about silent swallowing.
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