Skip to content

Spring Boot @Async Annotation — Part 2 | Self Invocation, Exception Handling and Traps

The Office Intercom Analogy

Imagine an executive working in an office suite. On the wall sits an intercom box connected to the company receptionist outside. When the executive wants a document delivered to legal, they press the intercom button and speak to the receptionist. The receptionist hires a bicycle courier and handles the delivery asynchronously while the executive returns to reviewing contracts.

Now imagine the executive stands up, walks to the whiteboard on the other side of their own office, and talks to themselves out loud: "I will now deliver this document." The receptionist outside cannot hear that internal monologue. No intercom was pressed, no proxy intercepted the request, and no bicycle courier was dispatched. The executive is forced to carry the document themselves.

In Spring Boot, Self Invocation is that internal monologue. The @Async annotation works through Spring AOP dynamic proxies. When you call an @Async method from an external bean, the call passes through the proxy, which intercepts it and dispatches it to a background thread pool worker. But when a method in class A calls another @Async method inside class A directly, the call bypasses the proxy entirely: execution runs synchronously on the same thread!

This lecture covers the self invocation trap, why methods must be public, handling exceptions in void asynchronous methods using AsyncUncaughtExceptionHandler, and propagating security and logging contexts across threads.


The Self Invocation Trap: Why @Async Silently Fails

This is one of the most notorious traps in Spring Boot interviews and production codebases.

Consider this service:

java
// BUG: Self-invocation causes @Async to execute SYNCHRONOUSLY!
@Service
public class OrderService {

    public void processOrder(Order order) {
        System.out.println("Processing order on thread: " + Thread.currentThread().getName());

        // Calling local method in same class directly
        sendConfirmationEmail(order.getEmail());
    }

    @Async
    public void sendConfirmationEmail(String email) {
        System.out.println("Sending email on thread: " + Thread.currentThread().getName());
        // Slow email delivery logic...
    }
}

When you run orderService.processOrder(order), the console outputs:

Processing order on thread: http-nio-8080-exec-1
Sending email on thread: http-nio-8080-exec-1

Both methods ran on the exact same HTTP request thread! The email delivery ran synchronously, blocking the caller.

Why Did This Happen?

Spring @Async is implemented using Dynamic Proxies:

[ External Caller ] ---> [ Spring CGLIB Proxy ] ---> [ Target OrderService Bean ]
                                   |
                     (Intercepts @Async call)
                     (Dispatches to Thread Pool)
  1. When an external controller calls orderService.processOrder(), the call goes through the proxy.
  2. But inside OrderService, when processOrder() calls sendConfirmationEmail(), it is invoking this.sendConfirmationEmail().
  3. The keyword this points to the raw target object, completely bypassing the CGLIB proxy wrapper.
  4. Because the proxy was never involved, the @Async annotation is never inspected, and the method executes as a standard synchronous Java method call.

How to Solve the Self Invocation Trap

There are three architectural solutions:

The cleanest design adhering to the Single Responsibility Principle. Move notification logic into its own dedicated bean:

java
@Service
public class NotificationService {

    @Async
    public void sendConfirmationEmail(String email) {
        System.out.println("Running on worker thread: " + Thread.currentThread().getName());
    }
}

@Service
public class OrderService {

    @Autowired
    private NotificationService notificationService;

    public void processOrder(Order order) {
        // Calls through Spring proxy for NotificationService! Works asynchronously!
        notificationService.sendConfirmationEmail(order.getEmail());
    }
}

Solution 2: Self Injection

Inject the proxy of the service into itself:

java
@Service
public class OrderService {

    @Autowired
    @Lazy
    private OrderService self;

    public void processOrder(Order order) {
        // Calls through the injected proxy!
        self.sendConfirmationEmail(order.getEmail());
    }

    @Async
    public void sendConfirmationEmail(String email) { /* ... */ }
}

Solution 3: Retrieve Proxy via AopContext

Enable exposeProxy = true in @EnableAspectJAutoProxy and call ((OrderService) AopContext.currentProxy()).sendConfirmationEmail(...).


Why @Async Methods Must Be public

@Async methods must be declared public.

If you declare an @Async method as private, protected, or package private:

  1. CGLIB proxies cannot override private methods.
  2. Spring's AOP interceptors will silently ignore the @Async annotation.
  3. The method will execute synchronously with zero compiler errors and zero warnings, creating a silent performance regression.

Handling Exceptions in Asynchronous Methods

When an @Async method returns CompletableFuture<T>, exception handling is straightforward: any unhandled exception is captured inside the returned future, and you can inspect it using .exceptionally(...) or .handle(...).

However, what happens when an @Async method returns void?

java
@Async
public void sendAuditReport() {
    throw new RuntimeException("Database down"); // Who catches this?
}

Because the calling thread returned long ago, the exception cannot propagate back to the caller. By default, the exception is logged, but you cannot alert monitoring systems or take corrective action.

The Solution: AsyncUncaughtExceptionHandler

Implement AsyncConfigurer to define a global handler for all unhandled exceptions in void asynchronous methods:

java
package com.example.orderservice.config;

import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;

import java.lang.reflect.Method;

@Configuration
@EnableAsync
public class CustomAsyncExceptionHandlerConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new CustomAsyncExceptionHandler();
    }

    public static class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {

        @Override
        public void handleUncaughtException(Throwable throwable, Method method, Object... params) {
            System.err.println(String.format(
                "[ASYNC ERROR] Exception in method '%s': %s",
                method.getName(),
                throwable.getMessage()
            ));

            for (int i = 0; i < params.length; i++) {
                System.err.println("  Param[" + i + "]: " + params[i]);
            }

            // Trigger alerts, PagerDuty, or publish to a dead letter queue here
        }
    }
}

Now, whenever an unhandled exception occurs inside any void @Async method, handleUncaughtException intercepts it with full access to the target Method metadata, parameter values, and root cause Throwable.


Context Propagation Across Thread Boundaries

Because asynchronous methods run on separate worker threads, thread local state does not propagate automatically:

  1. Security Context: SecurityContextHolder uses a standard ThreadLocal, so SecurityContextHolder.getContext().getAuthentication() returns null inside @Async methods!
  2. Logging Context (MDC): Correlation IDs stored in MDC vanish.

Fixing Security Context Propagation

Configure the SecurityContextHolder strategy to MODE_INHERITABLETHREADLOCAL:

java
// Propagates security context to child threads
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);

Or configure a DelegatingSecurityContextAsyncTaskExecutor around your ThreadPoolTaskExecutor.


Interview Questions & Pitfalls

Q1: What is the self invocation issue with @Async in Spring Boot, and why does it occur?

Self invocation occurs when a method calls another @Async annotated method in the same class directly. Because Spring @Async relies on dynamic AOP proxies to intercept calls and dispatch them to worker threads, calling a method internally via this.methodName() bypasses the proxy wrapper completely, causing the @Async method to execute synchronously on the caller thread.

Q2: Why must methods annotated with @Async be declared public?

Spring AOP uses CGLIB or JDK dynamic proxies to subclass or wrap target beans. Proxies can only override and intercept public methods. Declaring an @Async method as private prevents the proxy from intercepting the invocation, causing the annotation to be silently ignored and running the method synchronously.

Q3: How are unhandled exceptions caught in @Async methods that return void?

Because the caller thread has already completed and returned, exceptions thrown by void asynchronous methods cannot propagate up the call stack. They must be intercepted using an AsyncUncaughtExceptionHandler, configured by implementing Spring's AsyncConfigurer interface.

Q4: What happens to SecurityContextHolder data when execution moves into an @Async method?

By default, SecurityContextHolder uses MODE_THREADLOCAL, meaning security credentials and authenticated user tokens are stored in a thread local map that belongs strictly to the calling thread. The worker thread in the async thread pool has an empty security context. To propagate it, you must configure MODE_INHERITABLETHREADLOCAL or wrap the executor with DelegatingSecurityContextAsyncTaskExecutor.

Q5: Can @Transactional and @Async be placed on the exact same method?

While syntactically permitted, placing @Transactional and @Async on the same method is risky. Because the method executes on a separate thread, it runs inside its own isolated database transaction; it cannot participate in the caller's active database transaction. If the caller transaction later rolls back, the asynchronous transaction on the worker thread may have already committed. It is cleaner to separate transactional updates from asynchronous side effects into distinct service boundaries.