@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 threadno 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 == null

Why?

Security context is thread-bound. Async threads don't inherit it.

✅ Fix

Use a delegating executor:

DelegatingSecurityContextAsyncTaskExecutor

Or 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.