Spring Boot @Async — Complete Deep Explanation

 

Spring Boot @Async — Complete Deep Explanation

1. First understand what @Async actually does

Normally, Java executes method calls synchronously.

public void process() {

    method1();
    method2();
    method3();
}

Execution is:

Main Thread
   |
   |--> method1()
   |
   |--> method2()
   |
   |--> method3()
   |
   ↓
Return

The thread waits for every method to finish.

Now suppose:

public void process() {

    method1();

    sendEmail();

    method3();
}

Sending an email might take 2 seconds.

The whole request waits:

Request Thread
     |
     ↓
 method1()
     |
     ↓
 sendEmail()  ← 2 seconds
     |
     ↓
 method3()
     |
     ↓
 Response

With @Async:

@Async
public void sendEmail() {
    // email logic
}

Spring can execute it using another thread:

Request Thread
     |
     +---- method1()
     |
     +---- call sendEmail()
     |
     +---- method3()
     |
     ↓
 Response


Async Thread
     |
     ↓
 sendEmail()

So the original thread doesn’t have to wait.


2. How does Spring actually make this happen?

This is the most important concept.

When you write:

@Async
public void sendEmail() {
}

Spring doesn’t magically change the Java method.

Spring uses AOP + proxy + task executor.

Think of it as:

Your Controller
       |
       ↓
Spring Proxy
       |
       ↓
Checks @Async
       |
       ↓
Task Executor
       |
       ↓
Thread Pool
       |
       ↓
Your method()

The proxy intercepts the method call.

Instead of immediately executing:

sendEmail();

the proxy submits the task to an executor.

Conceptually:

executor.submit(() -> sendEmail());

You don’t write this yourself. Spring handles it.


3. What is @EnableAsync doing?

When you add:

@EnableAsync

you’re telling Spring:

“Enable asynchronous method execution and process @Async annotations.”

Example:

@SpringBootApplication
@EnableAsync
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Without it:

@Async
public void sendEmail() {
}

may simply behave like a normal synchronous method.


4. Why self-invocation breaks @Async

This is probably the #1 interview question.

Consider:

@Service
public class OrderService {

    public void processOrder() {

        System.out.println("Order processing");

        sendEmail();
    }

    @Async
    public void sendEmail() {

        System.out.println("Sending email");
    }
}

You might think:

processOrder()
     |
     ↓
sendEmail()
     |
     ↓
New Thread

But that’s not what happens.

It’s actually:

OrderService object
       |
       ↓
this.sendEmail()
       |
       ↓
Direct method call
       |
       ↓
NO Spring Proxy
       |
       ↓
NO @Async

Why?

Because @Async is applied by the Spring proxy, not by Java itself.


5. What does the proxy look like?

Conceptually, Spring creates something like:

                    Spring Proxy
                         |
                         ↓
              +----------------------+
              |   OrderService       |
              |                      |
Controller →  |  @Async sendEmail()  |
              +----------------------+

The controller calls:

orderService.sendEmail();

The actual object reference is effectively:

Controller
    ↓
Proxy
    ↓
OrderService

The proxy sees:

@Async

and sends the method to the executor.


6. Correct solution for self-invocation

Use another Spring bean.

EmailService

@Service
public class EmailService {

    @Async
    public void sendEmail() {

        System.out.println(
            "Email Thread = "
            + Thread.currentThread().getName()
        );
    }
}

OrderService

@Service
public class OrderService {

    private final EmailService emailService;

    public OrderService(EmailService emailService) {
        this.emailService = emailService;
    }

    public void processOrder() {

        System.out.println(
            "Order Thread = "
            + Thread.currentThread().getName()
        );

        emailService.sendEmail();
    }
}

Now:

Controller
    |
    ↓
OrderService
    |
    ↓
EmailService Proxy
    |
    ↓
@Async
    |
    ↓
Thread Pool
    |
    ↓
Email Thread

That’s why it works.


7. Why new EmailService() is wrong

Consider:

EmailService service = new EmailService();

service.sendEmail();

This object isn’t managed by Spring.

Therefore:

Your Object
     |
     ↓
No Spring Container
     |
     ↓
No Spring Proxy
     |
     ↓
@Async ignored

Correct:

@Service
public class EmailService {
}

and:

private final EmailService emailService;

public OrderService(EmailService emailService) {
    this.emailService = emailService;
}

Spring creates and injects the object.


8. What is the thread pool?

This is another important concept.

When many requests call:

@Async
public void sendEmail() {
}

you don’t want Spring creating unlimited threads.

Instead, tasks are submitted to a thread pool.

For example:

Thread Pool
--------------------------------
| Async-1                     |
| Async-2                     |
| Async-3                     |
| Async-4                     |
| Async-5                     |
--------------------------------

Suppose 100 requests arrive.

You might have:

100 tasks
     |
     ↓
Thread Pool
     |
     ├── Async-1 → Task 1
     ├── Async-2 → Task 2
     ├── Async-3 → Task 3
     ├── Async-4 → Task 4
     └── Async-5 → Task 5

Remaining tasks wait in the queue.


9. Configure your own executor

For production systems, a custom executor is usually preferable.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {

        ThreadPoolTaskExecutor executor =
                new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);

        executor.setThreadNamePrefix("Async-");

        executor.initialize();

        return executor;
    }
}

Then:

@Async("taskExecutor")
public void sendEmail() {

    System.out.println(
        Thread.currentThread().getName()
    );
}

You could get:

Async-1
Async-2
Async-3

10. Understand Core Pool vs Max Pool

Suppose:

corePoolSize = 5
maxPoolSize = 10
queueCapacity = 100

Think:

Incoming Tasks
      |
      ↓
5 Core Threads
      |
      ↓
Queue
      |
      ↓
Additional Threads
      |
      ↓
Maximum 10 Threads

The exact behavior depends on the executor’s queueing strategy, but conceptually:

  • corePoolSize → normal number of worker threads
  • maxPoolSize → maximum workers
  • queueCapacity → tasks waiting for workers

This is important when tuning an application under load.


11. What happens if the queue becomes full?

Suppose:

Core = 5
Max = 10
Queue = 100

and the application receives a huge number of tasks.

Eventually:

Threads → full
Queue → full

The executor needs a rejection policy.

You can configure one:

executor.setRejectedExecutionHandler(
    new ThreadPoolExecutor.CallerRunsPolicy()
);

Different policies include:

AbortPolicy
CallerRunsPolicy
DiscardPolicy
DiscardOldestPolicy

For production systems, choose this deliberately based on whether losing tasks is acceptable.


12. @Async with void

Simplest case:

@Async
public void sendEmail() {

    // email
}

Caller:

emailService.sendEmail();

System.out.println("Continue");

The caller doesn’t receive a result.

This is suitable when you don’t need a return value.

Examples:

Send notification
Audit log
Fire-and-forget event

13. @Async with CompletableFuture

Suppose you need a result.

Use:

@Async
public CompletableFuture<String> process() {

    String result = "Success";

    return CompletableFuture.completedFuture(result);
}

Caller:

CompletableFuture<String> future =
        service.process();

Now you can compose operations.

For example:

CompletableFuture<String> future =
        service.process();

future.thenAccept(result -> {
    System.out.println(result);
});

This is generally better than immediately calling:

future.get();

because:

future.get();

blocks the current thread.


14. @Async and exceptions

Consider:

@Async
public void process() {

    throw new RuntimeException("Database error");
}

The exception doesn’t naturally travel back to the original caller like a normal synchronous method call.

For void methods, configure:

AsyncUncaughtExceptionHandler

For example:

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler
    getAsyncUncaughtExceptionHandler() {

        return (exception, method, params) -> {

            System.out.println(
                "Method: " + method.getName()
            );

            System.out.println(
                "Exception: " + exception.getMessage()
            );
        };
    }
}

With CompletableFuture, you can handle errors through the future itself.


15. @Async + @Transactional

This is where things become interesting.

Suppose:

@Transactional
public void createOrder() {

    orderRepository.save(order);

    emailService.sendEmail();
}

and:

@Async
public void sendEmail() {
}

You now have two execution contexts:

Main Thread
    |
    | Transaction
    |
    ↓
save Order
    |
    ↓
Call @Async
    |
    +--------------------+
                         |
                         ↓
                    Async Thread
                         |
                         ↓
                    sendEmail()

The transaction isn’t automatically propagated to the async thread.

So don’t assume:

Transaction A
      ↓
Async Thread
      ↓
Same Transaction A

That’s generally incorrect.


16. Why this matters with database operations

Suppose:

@Transactional
public void createOrder() {

    Order order = saveOrder();

    emailService.process(order);
}

Then async:

@Async
public void process(Order order) {

    order.getCustomer().getAddress();
}

If customer or address is lazily loaded, you can encounter:

LazyInitializationException

because you’re operating in another thread/context.

A safer pattern is:

@Async
public void process(Long orderId) {

    Order order =
        orderRepository.findById(orderId)
                       .orElseThrow();

    // process
}

Pass an ID rather than relying on a persistence context from the caller.


17. @Async + SecurityContext

This is a subtle production issue.

Suppose the current request has:

SecurityContext
      |
      ↓
Logged-in user

You call:

@Async
public void process() {
}

The async thread may not automatically have the same security context.

So code such as:

SecurityContextHolder
    .getContext()
    .getAuthentication();

may not behave as you expect.

If security context propagation is required, configure an appropriate executor/delegating mechanism rather than assuming thread-local state follows automatically.


18. ThreadLocal doesn’t automatically move to async threads

This is a very important microservices concept.

Suppose:

ThreadLocal<String> requestId;

Main thread:

Thread A
requestId = ABC123

Async:

Thread B
requestId = ???

Because ThreadLocal belongs to a specific thread.

Therefore:

Thread A
  |
  +--- Request ID
  |
  +--- User context
  |
  +--- MDC


Thread B
  |
  +--- Different ThreadLocal

This becomes especially important for:

  • Logging
  • Trace IDs
  • Request IDs
  • Security context
  • Tenant IDs

19. @Async and MDC logging

Suppose you use:

MDC.put("requestId", "12345");

Then:

@Async
public void process() {
    log.info("Processing");
}

You may find that requestId is missing.

Why?

Because MDC is commonly backed by ThreadLocal.

The async thread is different.

A task decorator can be used to propagate context.

Conceptually:

Request Thread
     |
     | MDC = 12345
     |
     ↓
Task submitted
     |
     ↓
TaskDecorator copies MDC
     |
     ↓
Async Thread
     |
     | MDC = 12345

This is useful in real-world Spring Boot microservices.


20. @Async and @Transactional order

You may encounter:

@Async
@Transactional
public void process() {
}

This can work, but understand what it means.

The method runs asynchronously, and the transaction is created in the async thread when the transactional interceptor runs.

It does not mean the caller’s transaction magically moves to the async thread.

Conceptually:

Caller Thread
    |
    | calls async method
    ↓
Async Thread
    |
    ↓
@Transactional starts transaction
    |
    ↓
DB operation
    |
    ↓
Commit

That’s different from:

Caller Transaction
       ↓
Async Thread
       ↓
Same Transaction

21. @Async doesn’t make the entire service asynchronous

This is another common misunderstanding.

Suppose:

public void process() {

    methodA();

    asyncMethod();

    methodB();
}

Only:

asyncMethod();

is asynchronous.

methodA() and methodB() still execute on the calling thread unless they themselves are asynchronous.


22. Multiple @Async calls

Suppose:

emailService.sendEmail();
notificationService.sendNotification();
auditService.saveAudit();

All three are asynchronous.

Potentially:

Main Thread
     |
     +---- Email Task ------> Async-1
     |
     +---- Notification ----> Async-2
     |
     +---- Audit -----------> Async-3

This can significantly improve response time.

But it also introduces concurrency.

You need to think about:

  • Database consistency
  • Race conditions
  • Thread pool capacity
  • Ordering
  • Error handling
  • Retry
  • Duplicate execution

23. @Async doesn’t guarantee execution order

Suppose:

service.task1();
service.task2();
service.task3();

Don’t assume:

task1
task2
task3

The actual result might be:

task2
task1
task3

or:

task3
task1
task2

because multiple threads may execute independently.

If ordering is important, @Async alone isn’t the right abstraction.


24. @Async vs Kafka

Since you work with Spring Boot microservices and Kafka, this distinction is useful.

@Async

Application
    |
    ↓
Thread Pool
    |
    ↓
Method execution

It is primarily in-process asynchronous execution.

Kafka

Producer
    |
    ↓
Kafka
    |
    ↓
Consumer
    |
    ↓
Processing

Kafka provides a distributed messaging architecture with persistence, consumer groups, replay capabilities, and decoupling between services.

So:

@Async is not a replacement for Kafka.

For a simple email after an HTTP request:

@Async

may be enough.

For reliable cross-service event processing:

Kafka

is usually more appropriate.


25. @Async vs CompletableFuture

@Async determines where/how the method is executed.

CompletableFuture gives you a way to represent and compose the asynchronous result.

They are often used together:

@Async
public CompletableFuture<String> getData() {
    return CompletableFuture.completedFuture("Success");
}

Then:

future
    .thenApply(...)
    .thenAccept(...)
    .exceptionally(...);

26. @Async vs parallelStream()

These are also different.

@Async

You explicitly submit work to an executor:

@Async
public void process() {
}

parallelStream()

Java uses its parallel stream mechanism:

list.parallelStream()
    .map(...)
    .collect(...);

They solve different problems.

Don’t use parallelStream() just because you want a background task.


27. Why your @Async might still look synchronous

Imagine:

@Async
public void process() {

    Thread.sleep(10000);
}

Caller:

service.process();

return "Done";

If @Async works:

Request
  |
  ↓
process() submitted
  |
  ↓
Response "Done"
  |
  |
  +-------- Async processing continues

But if the response still waits 10 seconds, investigate:

  1. @EnableAsync
  2. Self-invocation
  3. Spring bean management
  4. Proxying
  5. Executor configuration
  6. Whether you’re actually calling the annotated method
  7. Whether some later operation is blocking

28. The easiest debugging technique

Put this in both methods.

Caller:

System.out.println(
    "Caller: "
    + Thread.currentThread().getName()
);

Async:

System.out.println(
    "Async: "
    + Thread.currentThread().getName()
);

Expected:

Caller: http-nio-8080-exec-1
Async: task-1

If you see:

Caller: http-nio-8080-exec-1
Async: http-nio-8080-exec-1

then your method probably isn’t being executed asynchronously.


29. Production example

Imagine an order API:

POST /orders

Without async:

Client
  |
  ↓
Create Order
  |
  ↓
Save DB
  |
  ↓
Send Email
  |
  ↓
Send SMS
  |
  ↓
Create Audit
  |
  ↓
Response

Maybe:

DB       = 100ms
Email    = 500ms
SMS      = 300ms
Audit    = 100ms
-------------------
Total    ≈ 1000ms

With async:

Client
  |
  ↓
Create Order
  |
  ↓
Save DB
  |
  +------> Email
  |
  +------> SMS
  |
  +------> Audit
  |
  ↓
Response

Potentially the API can return much sooner.

But there’s an important question:

Can the email/SMS/audit operation be lost if the application crashes after returning the response?

Yes.

That’s one reason reliable event-driven architectures may use Kafka/outbox patterns instead of simply relying on @Async.


30. The biggest mistake: using @Async for critical operations

Suppose:

@Async
public void transferMoney() {
    // critical banking transaction
}

You generally should not make critical business operations fire-and-forget just to make the API faster.

For banking-style operations:

Money transfer
Balance update
Transaction creation
Ledger update

you need strong transactional guarantees.

@Async is better for secondary work such as:

Email
Notification
Audit
Analytics
Non-critical background processing

31. A real-world architecture

For your Spring Boot microservices knowledge, remember this architecture:

                 API Request
                     |
                     ↓
              Order Controller
                     |
                     ↓
              Order Service
                     |
             +-------+-------+
             |               |
             ↓               ↓
          Database       Kafka Event
                             |
                             ↓
                       Notification
                         Service
                             |
                             ↓
                           Email

For simple applications:

Order Service
     |
     ↓
@Async
     |
     ↓
Email

For larger distributed systems:

Order Service
     |
     ↓
Kafka
     |
     ↓
Notification Service

🔥 Final mental model

If you remember only one thing, remember this:

                 @Async
                   |
                   ↓
             Spring AOP Proxy
                   |
                   ↓
             Task Executor
                   |
                   ↓
              Thread Pool
                   |
          +--------+--------+
          |        |        |
       Thread-1 Thread-2 Thread-3
          |
          ↓
       Your Method

And when @Async doesn’t work, ask these questions in this exact order:

1. Is @EnableAsync present?
             ↓
2. Is the method called through a Spring bean?
             ↓
3. Is this self-invocation?
             ↓
4. Did I use new MyService()?
             ↓
5. Is the method proxy-compatible?
             ↓
6. Is the correct executor being used?
             ↓
7. What thread name is executing it?
             ↓
8. Is something later calling get()/join() and blocking?
             ↓
9. Are ThreadLocal/Security/MDC contexts required?
             ↓
10. Do I actually need @Async, or should I use Kafka?

⭐ One-line interview answer

@Async works through Spring AOP proxies and a task executor. If the call bypasses the Spring proxy—especially through self-invocation or manually creating the object—@Async will not work.

This proxy concept is the key to understanding almost every @Async problem.

Leave a Reply