Skip to content
Chapter 17 · Spring Boot

@Async Part 2: Pitfalls, Return Types and Exceptions ​

Part 1 showed @Async and the thread pool behind it. This part covers what goes wrong in practice: when @Async silently does nothing, how it combines with @Transactional, how to return results with Future and CompletableFuture, and how to handle exceptions thrown by async methods.

When does @Async work? ​

Two conditions:

  1. The call must come from a different class. The @Async method must be called from another bean, not from a method in the same class.
  2. The method must not be private.

This does not work:

java
@RestController
@RequestMapping("/api")
public class UserController {

    @GetMapping("/getuser")
    public String getUserMethod() {
        System.out.println("inside getUserMethod: " + Thread.currentThread().getName());
        asyncMethodTest();   // same class
        return "done";
    }

    @Async
    public void asyncMethodTest() {
        System.out.println("inside asyncMethodTest: " + Thread.currentThread().getName());
    }
}
inside getUserMethod: http-nio-8080-exec-1
inside asyncMethodTest: http-nio-8080-exec-1

Same thread: no new thread was created. Move asyncMethodTest into another class (such as UserService) and inject it, as in part 1.

Why: @Async works through AOP (see the AOP chapter). Other beans do not get your object; they get a proxy around it, which intercepts the call and hands the method to an executor. A call inside the same class uses this, not the proxy, so there is no interception. And the proxy is a generated subclass, which cannot override a private method, so a private @Async method is never intercepted either. (Many articles say the method must be public. With Spring Boot 3's class-based proxies, protected and package-private @Async methods run asynchronously too; only private fails.)

@Async and @Transactional ​

A transaction belongs to a thread. Its context (the transaction itself, its propagation, and so on) is not passed to a new thread.

Use case 1: @Transactional caller, @Async callee (avoid) ​

java
@Component
public class UserService {

    @Autowired
    UserUtility userUtility;

    @Transactional
    public void updateUser() {
        // update user status, first name, last name...
        userUtility.updateUserBalance();
    }
}

@Component
public class UserUtility {

    @Async
    public void updateUserBalance() {
        // update the balance
    }
}

updateUserBalance runs in a new thread without any transaction. If something fails, the status and name updates are rolled back, but the balance update is not. Do not do this.

Use case 2: @Async and @Transactional on the same method (use with care) ​

java
@Component
public class UserService {

    @Async
    @Transactional
    public void updateUser() {
        // update user status, first name, last name...
    }
}

A new thread is created, and it runs in a new transaction of its own: failures inside are rolled back. The catch: if the caller has a transaction, that context does not reach the new thread, so propagation settings (for example "join the caller's transaction if there is one") do not work as you would expect.

java
@Component
public class UserService {

    @Autowired
    UserUtility userUtility;

    @Async
    public void updateUser() {
        userUtility.updateUser();
    }
}

@Component
public class UserUtility {

    @Transactional
    public void updateUser() {
        // update user status, first name, last name...
    }
}

The calling thread is free immediately. The new thread then starts a transaction in UserUtility, and all the work happens inside it. This is the safe and common way to combine the two.

Return types: Future and CompletableFuture ​

To get a result back from the new thread, return a Future or a CompletableFuture. (Both are covered in depth in the Java multithreading chapters.)

java
@Component
public class UserService {

    @Async
    public CompletableFuture<String> performTaskAsync() {
        // long work...
        return CompletableFuture.completedFuture("async task result");
    }
}
java
@GetMapping("/getuser")
public String getUserMethod() {
    CompletableFuture<String> result = userService.performTaskAsync();
    // the calling thread keeps working here...

    String output = null;
    try {
        output = result.get();   // waits until the async method has finished
        System.out.println(output);
    } catch (Exception e) {
        System.out.println("exception");
    }
    return output;
}

The call returns at once with a reference; get() blocks until the result is ready. CompletableFuture (Java 8) is an enhanced Future that also supports chaining, so it is the usual choice. The method may declare Future<String> and return the same value. (Older code returns Spring's new AsyncResult<>(...); that class is deprecated. The Future interface itself is not.)

Methods of Future:

MethodPurpose
boolean cancel(boolean mayInterruptIfRunning)Tries to cancel the task. Returns false if it cannot be cancelled (typically because it already finished).
boolean isCancelled()true if the task was cancelled before it completed.
boolean isDone()true if the task completed, normally, with an exception, or by cancellation.
V get()Waits if needed, then returns the result.
V get(long timeout, TimeUnit unit)Waits at most the timeout; throws TimeoutException if the task is still not done.

Exception handling ​

With a return type, it is simple. The exception is stored in the future and thrown when you call get(), wrapped in an ExecutionException:

java.util.concurrent.ExecutionException: java.lang.ArithmeticException: / by zero

Catch it around get() and handle it as you like.

With void, there is nothing to call get() on: the caller has already moved on, so who sees the exception? Two ways.

1. A try/catch inside the async method:

java
@Async
public void performTaskAsync() {
    try {
        // ...
    } catch (Exception e) {
        // log it, retry, ...
    }
}

2. An AsyncUncaughtExceptionHandler. If you do nothing, Spring uses its SimpleAsyncUncaughtExceptionHandler, which just logs:

java
public class SimpleAsyncUncaughtExceptionHandler implements AsyncUncaughtExceptionHandler {

    private static final Log logger = LogFactory.getLog(SimpleAsyncUncaughtExceptionHandler.class);

    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        if (logger.isErrorEnabled()) {
            logger.error("Unexpected exception occurred invoking async method: " + method, ex);
        }
    }
}
ERROR [task-5] .a.i.SimpleAsyncUncaughtExceptionHandler : Unexpected exception occurred invoking async method: public void com.example.UserService.performTaskAsync()
java.lang.ArithmeticException: / by zero

For your own handling, implement the same interface and register it through AsyncConfigurer:

java
@Component
public class DefaultAsyncUncaughtExceptionHandler implements AsyncUncaughtExceptionHandler {

    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        System.out.println("in default uncaught exception method: " + method.getName() + " " + ex);
    }
}
java
@Configuration
public class AppConfig implements AsyncConfigurer {

    @Autowired
    DefaultAsyncUncaughtExceptionHandler asyncUncaughtExceptionHandler;

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return asyncUncaughtExceptionHandler;
    }
}

Now every exception escaping a void @Async method goes to your handler. This saves writing a try/catch in each async method; choose based on what your business logic needs.

Next: That completes AOP, transactions and async work. The next module is about handling HTTP requests and responses. Chapter 18 starts with custom interceptors: running your own code before a request reaches a controller, and using AOP with a custom annotation of your own.

Check yourself ​

  1. What two conditions must hold for @Async to run the method in a new thread? Why?
  2. A controller calls its own @Async method. Which thread runs it?
  3. Is a protected @Async method run asynchronously in Spring Boot 3? A private one?
  4. A @Transactional method calls an @Async method in another bean, which then fails. What is rolled back?
  5. What is the risk of putting @Async and @Transactional on the same method?
  6. What is the recommended way to combine them?
  7. How do you get a result back from an @Async method, and when does the caller wait?
  8. An @Async method returning CompletableFuture throws. Where and how does the caller see the exception?
  9. An @Async void method throws and nothing is configured. What happens?
  10. How do you plug in your own handler for such exceptions?
Answers
  1. It must be called from a different bean, and must not be private. @Async works through an AOP proxy; same-class calls bypass the proxy, and a private method cannot be overridden by the proxy subclass.
  2. The caller's own thread, for example http-nio-8080-exec-1.
  3. Yes. No.
  4. The caller's own updates are rolled back. The async method's work ran without a transaction, so it is not.
  5. It runs in a new, separate transaction; the caller's transaction context is not carried over, so propagation settings do not behave as expected.
  6. @Async on the outer method, which calls a @Transactional method in another bean: the whole transaction runs in the new thread.
  7. Return Future or CompletableFuture. The caller waits only when it calls get().
  8. At get(), as an ExecutionException wrapping the original exception.
  9. SimpleAsyncUncaughtExceptionHandler logs "Unexpected exception occurred invoking async method" with the method and stack trace.
  10. Implement AsyncUncaughtExceptionHandler and return it from getAsyncUncaughtExceptionHandler() in an AsyncConfigurer.