skip to content

How can a CompletableFuture silently swallow an exception, and what practices ensure failures are observed?

level: middleimportance: must knowfreq 65%

answer

  1. Throw inside a stage → completes exceptionally, not thrown at call site
  2. Invisible until join/get or exceptionally/handle consumes it
  3. Abandoned future = silent swallow (no UncaughtExceptionHandler)
  4. whenComplete observes but re-raises; handle/exceptionally recover
  5. exceptionally only catches upstream failures

basics

~20 s

If a stage throws and you never call join()/get() and never add exceptionally()/handle(), the failure is stored inside the future and nobody ever sees it — no log, no crash. Always terminate a chain by either joining it or attaching an error handler.

solid answer

~50 s

A failing stage doesn't throw on the spot — it completes the future *exceptionally*, capturing the throwable inside the future object. The exception only surfaces when something *consumes* it: a blocking get()/join() (which rethrows wrapped in CompletionException/ExecutionException), or an error-handling stage (exceptionally, handle, whenComplete). If you fire-and-forget a chain — never join, never attach a handler — the exception sits in the unreferenced future and is garbage-collected silently. There's no default 'uncaught exception' for an abandoned future, unlike a thread's run(). The practices: always either join the future (and let the exception propagate) or attach exceptionally/handle to log/recover; add whenComplete for logging side-effects (it observes but rethrows the original); and beware that exceptionally only handles failures of the *upstream* stages, not of itself. For collections of futures, allOf() lets you join the aggregate so no failure is dropped.

code

java · 15 lines
java
// PITFALL: fire-and-forget, exception vanishes
CompletableFuture.supplyAsync(this::loadData)
                 .thenApply(this::transform);   // no join, no handler

// FIX 1: log every outcome at the boundary
CompletableFuture.supplyAsync(this::loadData)
                 .thenApply(this::transform)
                 .whenComplete((v, ex) -> {
                     if (ex != null) log.error("task failed", ex);
                 });

// FIX 2: recover with a fallback
String result = CompletableFuture.supplyAsync(this::loadData)
                 .exceptionally(ex -> defaultValue)
                 .join();

go deeper

for a junior

Understands that an exception inside the future doesn't immediately crash and that you should add an error handler or join; may not know the exact handler semantics.

for a middle

Explains that a stage completing exceptionally is invisible until consumed, that there's no UncaughtExceptionHandler for futures, and uses exceptionally/handle/whenComplete appropriately.

for a senior

Distinguishes recover-vs-observe semantics, handles wrapping (CompletionException/ExecutionException, getCause), reasons about handler placement and allOf aggregation, and sets boundary-logging discipline.

for a principal

Establishes team patterns/lint rules so no future is fire-and-forgotten without a handler, integrates failure observation with metrics/tracing, and reasons about failure propagation across composed async subsystems.

## Background: how a CompletableFuture fails A `CompletableFuture<T>` completes in one of two ways: **normally** (with a value) or **exceptionally** (with a `Throwable`). When the lambda you pass to `supplyAsync` or `thenApply` throws, the framework *catches* that throwable and uses it to complete the future exceptionally — it does **not** propagate up the call stack at that moment, because the lambda is running on some pool thread, not your call site. So the throwable is now *stored inside the future object*. Whether anyone ever learns about it depends entirely on whether the result is **consumed**. ## The three ways a failure becomes visible 1. **Blocking accessors** — `join()` rethrows the throwable wrapped in an unchecked `CompletionException`; `get()` wraps it in a checked `ExecutionException`. This is the most direct way: if you join, you'll see it. 2. **Error-handling stages** — `exceptionally(fn)` runs `fn` with the throwable if upstream failed (and lets you substitute a fallback value); `handle((v, ex) -> ...)` always runs and gets both value and exception; `whenComplete((v, ex) -> ...)` observes both as a side-effect and *re-raises* the original outcome (it does not swallow). 3. **Downstream propagation** — an exceptional completion flows *down* the chain: dependent stages like `thenApply` are skipped and the exception is passed along until something handles it or the chain ends. ## The pitfall: nobody consumes the result Consider: ```java CompletableFuture.supplyAsync(this::loadData) // throws inside .thenApply(this::transform); // skipped on failure // ...and we never join, never handle. Method returns void. ``` Here the exception completes the future exceptionally, `thenApply` is skipped, and the resulting future — *which we never assigned, joined, or handled* — becomes unreachable and is eventually garbage-collected. **No stack trace is printed, no log line appears, nothing crashes.** This is the silent-swallow. Unlike a raw thread (whose escaping exception hits the thread's `UncaughtExceptionHandler`), an abandoned CompletableFuture has no such safety net. ## Subtle variants - **`exceptionally` placement.** `exceptionally` only catches failures from stages *above* it. If a *later* stage throws, an earlier `exceptionally` won't see it. Put recovery where it can observe the failures you care about, or use `handle` at the end. - **`whenComplete` doesn't recover.** `whenComplete` lets you *observe* (e.g. log) but the future still completes with the original outcome — if it was a failure, downstream still fails. Use `handle`/`exceptionally` to actually *recover*. - **A handler that itself throws** produces a new exceptional completion, which can again be swallowed if not consumed. - **Aggregates.** `CompletableFuture.allOf(a, b, c)` completes exceptionally if *any* input failed; but you only learn this by joining the `allOf` result. The individual futures still each hold their own outcome. ## Practices that guarantee observation 1. **Always terminate the chain.** Either `join()` it (let the exception propagate to a caller who handles it) or attach a final `exceptionally`/`handle` that logs or recovers. 2. **Log at the boundary.** A trailing `.whenComplete((v, ex) -> { if (ex != null) log.error("task failed", ex); })` ensures every failure is at least recorded. 3. **Don't fire-and-forget without a handler.** If you must not block, still attach an error handler before discarding the reference. 4. **For batches, join the `allOf`** (or stream + handle each) so no member's failure is dropped. 5. **Unwrap correctly.** When you do catch, the throwable is wrapped (`CompletionException`/`ExecutionException`); inspect `getCause()` for the real error. ## Mental model A CompletableFuture failure is *latent*: it exists but is invisible until consumed. "Consume every future" — by joining or handling — is the discipline that converts latent failures into visible ones.

  • What's the difference between exceptionally, handle, and whenComplete?
    exceptionally(fn) runs only on failure and substitutes a recovery value. handle((v,ex)->...) always runs, sees both value and exception, and can transform either into a result (so it can recover). whenComplete((v,ex)->...) always runs as a side-effect (e.g. logging) but does NOT change the outcome — the future completes with the original value or exception.
  • When you join() a failed future, what exception type do you catch, and how do you get the real cause?
    join() throws an unchecked CompletionException (get() throws checked ExecutionException). The original throwable is in getCause(). Always unwrap before logging/handling so you don't hide the real error behind the wrapper.

saying these in an interview costs you the question

  • Assuming an exception in a stage prints a stack trace by default
  • Thinking a CompletableFuture has an uncaught-exception handler like a Thread
  • Believing whenComplete swallows/recovers the error (it re-raises)
  • Putting exceptionally early and expecting it to catch a later stage's failure
  • Forgetting that get/join wrap the cause in ExecutionException/CompletionException

context