How Does @Async Work Internally in Spring Boot?
Source: Dev.to
Introduction 🚀
Have you ever called a REST API and thought:
“Why is this request blocking until everything finishes?”
Now imagine sending an email, generating a report, or calling a slow third‑party service — do you really want your user to wait?
That’s where @Async in Spring Boot comes to the rescue.
In simple terms, @Async lets Spring say:
“You go ahead, I’ll handle this task in the background.”
Behind this simple annotation, Spring Boot uses proxies, thread pools, and executors to run your code asynchronously — and understanding how it works internally helps you avoid production bugs and interview traps.
In this blog, you’ll learn:
- What
@Asyncreally does behind the scenes - How Spring Boot executes async methods
- Two complete, end‑to‑end Java 21 examples
- Common mistakes and best practices
Core Concepts 🧠
What Is @Async in Spring Boot?
@Async is a Spring annotation that allows a method to:
- Run in a separate thread
- Return immediately to the caller
- Execute logic asynchronously using a
TaskExecutor
Think of it like ordering food online 🍔:
- You place the order (API call)
- The restaurant prepares it in the background
- You don’t stand at the counter waiting
How @Async Works Internally (Simple Explanation)
Internally, Spring Boot uses Spring AOP (proxy‑based mechanism).
- Spring creates a proxy for your bean.
- When an
@Asyncmethod is called, the proxy intercepts the call. - The proxy submits the method to a
TaskExecutor. - The executor runs the method in a separate thread.
- The main thread continues immediately.
Key takeaway:
@Asyncworks only when the method is called from another Spring‑managed bean.
Use Cases & Benefits
✅ Best use cases
- Sending emails
- Calling slow external APIs
- Background processing
- Event handling
- Audit logging
🎯 Benefits
- Faster API responses
- Better user experience
- Cleaner separation of concerns
End‑to‑End Setup (Java 21 + Spring Boot) ⚙️
We’ll build:
- A REST API
- An async service
- A custom thread pool
- cURL request + response
Example 1: Basic @Async Execution
1️⃣ Enable Async Support
@Configuration
@EnableAsync
public class AsyncConfig {
}
This tells Spring to look for
@Asyncmethods.
2️⃣ Async Service
@Service
public class NotificationService {
@Async
public void sendNotification() throws InterruptedException {
// Simulate long‑running task
Thread.sleep(3000);
System.out.println("Notification sent by thread: " +
Thread.currentThread().getName());
}
}
3️⃣ REST Controller
@RestController
@RequestMapping("/api")
public class NotificationController {
private final NotificationService service;
public NotificationController(NotificationService service) {
this.service = service;
}
@GetMapping("/notify")
public String triggerNotification() throws InterruptedException {
service.sendNotification();
return "Request accepted. Processing asynchronously.";
}
}
4️⃣ cURL Request
curl -X GET http://localhost:8080/api/notify
✅ Response
Request accepted. Processing asynchronously.
The API returns immediately, while the task runs in the background.
Example 2: @Async with CompletableFuture (Recommended)
Why use this?
- Better control
- Non‑blocking result handling
- Cleaner async composition
1️⃣ Async Service with Return Value
@Service
public class ReportService {
@Async
public CompletableFuture<String> generateReport() throws InterruptedException {
Thread.sleep(2000);
return CompletableFuture.completedFuture("Report generated successfully");
}
}
2️⃣ REST Controller
@RestController
@RequestMapping("/api")
public class ReportController {
private final ReportService service;
public ReportController(ReportService service) {
this.service = service;
}
@GetMapping("/report")
public CompletableFuture<String> generate() throws InterruptedException {
return service.generateReport();
}
}
3️⃣ cURL Request
curl -X GET http://localhost:8080/api/report
✅ Response
Report generated successfully
The request thread is released early, while the task runs asynchronously.
Custom Thread Pool (Highly Recommended) 🧵
@Configuration
@EnableAsync
public class AsyncExecutorConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-worker-");
executor.initialize();
return executor;
}
}
Without this, Spring falls back to a
SimpleAsyncTaskExecutor, which is not production‑friendly.
Best Practices ✅
- Never call
@Asyncinside the same class – self‑invocation bypasses Spring proxies. - Always define a custom executor – avoid unbounded thread creation.
- Use
CompletableFuturefor return values – gives you better async handling and composition. - Keep async logic idempotent and exception‑safe; handle failures inside the async method.
- Monitor thread‑pool metrics (active threads, queue size) in production.
Common Mistakes ❌
- Forget
@EnableAsync - Expect async behavior on private methods
- Block async threads with heavy logic
- Use
@Asyncfor CPU‑heavy tasks - Assume transactions propagate automatically
Tips
- Handle exceptions explicitly – async exceptions won’t propagate automatically.
- Keep async logic lightweight –
@Asyncis not a replacement for message queues.
Conclusion 🧩
@Async in Spring Boot looks simple, but internally it relies on:
- Spring AOP proxies
- Task executors
- Thread pools
Understanding these internals helps you:
- Avoid subtle bugs
- Write scalable applications
- Impress interviewers 😉
Call to Action 📣
💬 Have questions about @Async or async bugs you’ve faced?
🧠 Comment below — let’s discuss!
⭐ Follow for more Java programming and Spring Boot internals content.