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
@Asyncannotations.”
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 threadsmaxPoolSize→ maximum workersqueueCapacity→ 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:
@Asyncis 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:
@EnableAsync- Self-invocation
- Spring bean management
- Proxying
- Executor configuration
- Whether you’re actually calling the annotated method
- 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
@Asyncworks through Spring AOP proxies and a task executor. If the call bypasses the Spring proxy—especially through self-invocation or manually creating the object—@Asyncwill not work.
This proxy concept is the key to understanding almost every @Async problem.