skip to content

Compare exception handling for a void @Async method versus one returning Future or CompletableFuture. When would you choose each?

level: seniorimportance: must knowfreq 60%

answer

  1. return type = who owns the failure
  2. void -> AsyncUncaughtExceptionHandler (log)
  3. Future.get() -> ExecutionException (unwrap getCause)
  4. CompletableFuture -> exceptionally/handle/whenComplete
  5. unread future = silently swallowed, no handler

basics

~20 s

Void: the exception can't reach the caller, so Spring sends it to the AsyncUncaughtExceptionHandler (default: log). Future/CompletableFuture: the exception is stored in the future and re-thrown when you call get() (as ExecutionException) or handled via exceptionally/handle. Use void for fire-and-forget, futures when you need the result or failure.

solid answer

~40 s

The return type decides who observes failures. With void, the caller has no handle on the outcome, so an uncaught exception is routed to the AsyncUncaughtExceptionHandler (default SimpleAsyncUncaughtExceptionHandler logs at ERROR) — good only for fire-and-forget work. With Future<T>, the exception is captured and re-thrown wrapped in an ExecutionException when the caller calls get(); the caller must block and catch it. With CompletableFuture<T> you get the same capture plus non-blocking composition: exceptionally(fn), handle(bifn), whenComplete(bicon) let you recover or react without blocking, and it chains with thenCompose/thenCombine. The AsyncUncaughtExceptionHandler is NOT invoked for future-returning methods. Choose void for pure side effects where callers needn't know; choose CompletableFuture whenever the caller must consume a result, aggregate multiple async calls, apply fallbacks/timeouts, or propagate failure into the request flow.

code

java · 30 lines
java
@Service
public class ReportService {

    @Async
    public void archive(Long id) {            // fire-and-forget
        throw new IllegalStateException("archive " + id);
        // -> AsyncUncaughtExceptionHandler (default logs ERROR)
    }

    @Async
    public CompletableFuture<Report> build(Long id) {
        return CompletableFuture.completedFuture(load(id));
        // if this throws, the failure lands in the returned future
    }
}

// Consuming futures — failure IS observable here
reportService.build(7L)
    .thenApply(Report::summary)
    .exceptionally(ex -> {                    // recover without blocking
        log.error("build failed", ex);
        return "n/a";
    });

// Legacy Future path — must unwrap ExecutionException
try {
    legacyFuture.get();
} catch (ExecutionException e) {
    Throwable real = e.getCause();            // the actual business exception
}

go deeper

for a junior

Knows void logs, futures carry the error.

for a middle

Explains ExecutionException wrapping and basic exceptionally usage.

for a senior

Contrasts all three return types and warns about unobserved futures swallowing errors.

for a principal

Chooses return contract per use case and standardizes async result/failure conventions across services.

## The single decision: return type = failure ownership `@Async` supports three return shapes, and each defines a completely different failure contract. ### 1. `void` — fire-and-forget - Caller gets nothing back and cannot observe success or failure. - An uncaught exception is delivered to the **`AsyncUncaughtExceptionHandler`** (default **`SimpleAsyncUncaughtExceptionHandler`** → log ERROR). - No blocking, no result, no composition. - Use for: audit logs, notifications, cache warmups — side effects where the caller's flow doesn't depend on the outcome, and where a global handler gives enough observability. ### 2. `Future<T>` (legacy `java.util.concurrent.Future`) - Spring returns a completed/failed future carrying either the value or the exception. - The caller retrieves via `future.get()`, which **blocks** until done and, on failure, throws **`ExecutionException`** wrapping the original cause (use `getCause()`). `get()` also throws `InterruptedException`. - No callbacks, no composition — you must poll `isDone()` or block on `get()`. - The uncaught-exception handler is **not** used. - Prefer returning `CompletableFuture` in modern code; Spring recommends it. ### 3. `CompletableFuture<T>` (modern, preferred) - Same capture semantics as `Future` but with a rich, **non-blocking** API: - `exceptionally(Throwable -> T)` — supply a fallback value on failure. - `handle((T, Throwable) -> R)` — react to both outcomes and transform. - `whenComplete((T, Throwable) -> void)` — side-effect on completion without changing the value. - `thenApply/thenCompose/thenCombine/allOf` — compose and aggregate multiple async results. - `join()` behaves like `get()` but throws unchecked `CompletionException`. - The uncaught-exception handler is **not** used — failures live in the future. - Use for: anything where the caller consumes a result, needs fallbacks/timeouts (`orTimeout`, `completeOnTimeout`), or fans out/joins multiple async operations. ## Common gotchas - **Wrapping:** `Future.get()` throws `ExecutionException`; always unwrap `getCause()` to see the real exception. `CompletableFuture.join()` throws `CompletionException`. - **Silent futures:** if you return a `CompletableFuture` but never call `get()/join()` and never attach `exceptionally/handle/whenComplete`, the failure is **effectively swallowed** — no handler catches it either. A future-returning method whose result nobody inspects is worse than void (void at least logs). - **Return the executor's future, not a new one:** in your `@Async` body return `CompletableFuture.completedFuture(x)` or let the body throw; Spring wires the completion. Don't manually spin another thread. - **Handler scope:** `AsyncUncaughtExceptionHandler` applies to void only — a frequent trap. - **Blocking defeats the purpose:** immediately calling `get()` right after the async call makes it synchronous again; use composition instead. ## Decision guide | Need | Return | |---|---| | Pure side effect, caller indifferent | `void` | | Caller must read the result or catch failure, legacy API | `Future<T>` | | Result + non-blocking recovery/composition/aggregation | `CompletableFuture<T>` |

  • You return a CompletableFuture from an @Async method, it fails, but nobody calls get() or attaches a callback. What happens to the exception?
    It is effectively swallowed. The failure sits in the unobserved future; the AsyncUncaughtExceptionHandler is not called (it's void-only). Nothing is logged unless a callback or get() surfaces it — arguably worse than void, which at least logs by default.
  • What exception type does Future.get() throw on failure, and how do you get the real cause?
    ExecutionException wrapping the original throwable; call getCause() to retrieve the real exception. (CompletableFuture.join() throws CompletionException instead.) get() can also throw InterruptedException.

saying these in an interview costs you the question

  • Saying the AsyncUncaughtExceptionHandler catches CompletableFuture failures
  • Forgetting Future.get() wraps the cause in ExecutionException
  • Claiming an unobserved CompletableFuture failure is logged by default
  • Immediately calling get() and thinking the call is still asynchronous

context