@Async
public void sendEmail() {
// background work
}No waiting. No blocking. Instant performance win… right?
Not exactly.
In real Spring Boot apps, @Async often introduces subtle, painful bugs that don't show up in tests — only in production.
Let's talk about why.
🧠 The First Big Misunderstanding
@Async does not make code "faster".
It makes code:
- Run in another thread
- Lose context
- Execute later
That change has consequences.
🚨 Bug #1: Transactions Don't Work the Way You Think
@Transactional
public void placeOrder() {
saveOrder();
sendEmailAsync();
}
@Async
public void sendEmailAsync() {
emailService.send();
}You expect:
- Order saved
- Email sent after commit
Reality:
- Email may send before commit
- Or send even if transaction rolls back
Why?
Because @Async runs in a different thread
→ no transaction context.
✅ Fix
Trigger async work after commit:
@TransactionalEventListener(phase = AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
sendEmailAsync();
}🚨 Bug #2: Security Context Disappears
Inside controllers:
@Async
public void audit() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
}Surprise:
auth == nullWhy?
Security context is thread-bound. Async threads don't inherit it.
✅ Fix
Use a delegating executor:
DelegatingSecurityContextAsyncTaskExecutorOr pass what you need explicitly.
🚨 Bug #3: Exceptions Are Silently Swallowed
@Async
public void process() {
throw new RuntimeException("Boom");
}What happens?
Nothing. No crash. No rollback. Sometimes no logs.
✅ Fix
Return CompletableFuture:
@Async
public CompletableFuture<Void> process() {
// ...
}Or configure an async exception handler.
🚨 Bug #4: JPA Entities Break in Async Methods
@Async
public void processOrder(Order order) {
order.getItems().size();
}Boom 💥:
LazyInitializationException
Why?
- Entity attached to original thread
- Persistence context closed
- Async thread has no session
✅ Fix
- Pass IDs, not entities
- Fetch data inside async method
🚨 Bug #5: Thread Pool Saturation (The Slow App Paradox)
Default async executor:
- Small pool
- Unbounded queue
Under load:
- Tasks pile up
- Memory grows
- App slows down
Async made things worse.
✅ Fix: Custom Executor
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.initialize();
return executor;
}Async needs capacity planning.
🚨 Bug #6: Async Inside the Same Class Doesn't Work
@Async
public void asyncMethod() {}
public void call() {
asyncMethod(); // not async
}Why?
Spring uses proxies. Self-invocation bypasses them.
✅ Fix
- Call from another bean
- Or restructure logic
🚨 Bug #7: Tests Lie to You
- Async code runs fast locally
- Tests don't wait for completion
- Failures don't surface
Then prod explodes.
✅ Fix
- Await async results
- Use
CompletableFuture - Test async explicitly
🧠 When @Async Is a Good Idea
✔ Fire-and-forget notifications ✔ Non-critical background tasks ✔ Logging & auditing ✔ Low-volume async work
If it's business-critical — think twice.
🚀 Better Alternatives
- Messaging (Kafka, RabbitMQ)
- Task queues
- Scheduled jobs
- Event-driven async
They're explicit, observable, and safer.
📈 Why This Blog Goes Viral
- Everyone uses
@Async - Bugs are subtle & painful
- Clear explanations
- Real fixes
- Senior-level insight
This is trust-building content.
🎯 Final Thought
@Async doesn't create bugs.
It reveals assumptions you didn't know you were making.
Use it carefully. Design for async — don't sprinkle it.
That's how production systems survive.