skip to content

What happens to an exception thrown inside a void @Async method, and how is that different from a normal (synchronous) method call?

level: juniorimportance: must knowfreq 70%

answer

  1. different thread = no caller stack to throw into
  2. void = swallowed from caller, logged by default
  3. SimpleAsyncUncaughtExceptionHandler logs ERROR
  4. caller @Transactional won't roll back
  5. return CompletableFuture to observe failure

basics

~20 s

In a synchronous call the exception bubbles up to the caller. In a void @Async method it runs on another thread, so the caller never sees it — Spring catches it and, by default, just logs it.

solid answer

~40 s

A void @Async method runs on a separate thread from an executor, so the caller has already returned before the method finishes — there is no call stack to propagate the exception back to. The caller's try/catch cannot see it. Spring's async support catches the uncaught exception and hands it to an AsyncUncaughtExceptionHandler; the default (SimpleAsyncUncaughtExceptionHandler) logs it at ERROR level. So the exception is not lost, but it is effectively 'swallowed' from the caller's point of view: no rollback signal reaches the caller, no return value carries the failure. If you need the caller to observe failures, return a Future/CompletableFuture instead of void, or register a custom handler to alert/record on error.

code

java · 25 lines
java
@Service
public class NotificationService {

    // Fire-and-forget: exception is caught by Spring, logged by
    // SimpleAsyncUncaughtExceptionHandler, and NOT seen by the caller.
    @Async
    public void sendWelcomeEmail(Long userId) {
        throw new IllegalStateException("SMTP down for user " + userId);
    }

    // Returns a future: the caller CAN observe the failure.
    @Async
    public CompletableFuture<String> render(Long userId) {
        throw new IllegalStateException("render failed for " + userId);
    }
}

// Caller
service.sendWelcomeEmail(1L);   // try/catch here would never fire

service.render(1L)
       .exceptionally(ex -> {   // this DOES fire
           log.error("render failed", ex);
           return "fallback";
       });

go deeper

for a junior

Must know that void @Async exceptions don't reach the caller and are logged by default.

for a middle

Should explain the missing call stack and that caller transactions are unaffected.

for a senior

Connects to choosing Future/CompletableFuture vs void based on whether failures must be observed.

for a principal

Frames it as an at-least-once/fire-and-forget design decision with explicit failure-capture strategy.

## The core problem `@Async` (enabled by `@EnableAsync`) makes Spring run the annotated method on a **different thread**, pulled from a `TaskExecutor`. The proxy Spring creates around your bean submits the method body to that executor and immediately returns to the caller. With a **void** return type, the caller gets `void` back the instant the task is *submitted* — long before the method body actually runs. There is therefore **no shared call stack** between the caller and the async method. In normal (synchronous) Java, an exception propagates *up the call stack* to the nearest matching `catch`. When the work runs on another thread, the caller's stack is already gone, so the exception has nowhere to propagate to. ```java try { service.sendEmailAsync(); // returns immediately } catch (Exception e) { // NEVER runs for a failure inside the async body } ``` ## What Spring actually does with it The exception is **not** silently dropped into the void. Spring's `AsyncExecutionAspectSupport` wraps execution of void `@Async` methods and, on an uncaught throwable, calls an **`AsyncUncaughtExceptionHandler`**. The default implementation is **`SimpleAsyncUncaughtExceptionHandler`**, which **logs the exception at ERROR level** with the method that threw it. So by default you *do* get a stack trace in the logs — but the caller is completely unaware, and nothing downstream (a transaction rollback in the caller, an HTTP error response, a retry) is triggered. Key consequence: **`@Transactional` on the caller does not roll back** because of an async failure — the async method runs in its own thread and (if annotated) its own transaction; the caller's transaction already committed/returned. ## Why 'swallowed' is the word people use - The caller's `try/catch` is useless. - The return value (`void`) carries no failure signal. - Business flow continues as if the call succeeded. Only the log line remains — and if logging is misconfigured or the default handler is replaced badly, even that can disappear. ## The fix / alternative If the caller (or any code) must **observe** the outcome, don't return void. Return `Future<T>` or, preferably, `CompletableFuture<T>`. Then the exception is captured *inside* the future and re-thrown when someone calls `future.get()` (wrapped in `ExecutionException`) or is delivered to `.exceptionally(...)`/`.handle(...)` callbacks. For fire-and-forget void methods, register a custom `AsyncUncaughtExceptionHandler` (via `AsyncConfigurer`) to do more than log — alert, increment a metric, persist a dead-letter record. ## Gotcha summary - Void async exceptions are logged, not propagated. - Caller try/catch and caller `@Transactional` are blind to them. - Use `CompletableFuture` when the result/failure matters. - Only *uncaught* exceptions reach the handler — a `try/catch` inside the method obviously stops it.

  • If the exception is 'swallowed', is it lost entirely?
    No. Spring routes uncaught exceptions from void @Async methods to an AsyncUncaughtExceptionHandler; the default SimpleAsyncUncaughtExceptionHandler logs them at ERROR level. It is invisible to the caller but visible in the logs (unless you replace the handler).
  • Will the caller's @Transactional roll back if the async method throws?
    No. The async method runs on its own thread with its own (optional) transaction. The caller's transaction is independent and unaffected by the async failure.

saying these in an interview costs you the question

  • Claiming the caller's try/catch will catch an exception from a void @Async method
  • Saying the exception is completely lost / never logged
  • Thinking the caller's transaction rolls back when the async method throws
  • Believing @Async re-throws synchronously to the caller

context