Senior 10 min · May 23, 2026

Spring @Async — Async Methods, ThreadPoolTaskExecutor, CompletableFuture, and the Pitfalls That Bite

Master Spring @Async: configure ThreadPoolTaskExecutor, return CompletableFuture, handle exceptions, and avoid the self-invocation and proxy pitfalls that silently break async execution.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • @Async only works when called from outside the bean — self-invocation bypasses the proxy and runs synchronously
  • Always configure a ThreadPoolTaskExecutor bean named 'taskExecutor' to replace the SimpleAsyncTaskExecutor default
  • Return CompletableFuture from @Async methods to chain async operations and handle exceptions
  • @Async exceptions don't propagate to the caller — configure AsyncUncaughtExceptionHandler for void methods
  • @EnableAsync must be on a @Configuration class; without it @Async is silently ignored
✦ Definition~90s read
What is Spring @Async?

Spring @Async is an aspect-oriented annotation that intercepts method calls and submits the method execution to a TaskExecutor (thread pool) asynchronously. When a @Async-annotated method is called through a Spring-managed proxy, the calling thread returns immediately (or gets a CompletableFuture/Future to track the result), while the method executes on a separate thread from the configured thread pool.

Spring @Async is like handing a task to a coworker instead of doing it yourself.

The implementation relies on Spring AOP. Spring wraps the bean in a proxy that intercepts calls to @Async methods. When such a call is intercepted, the proxy submits the actual method execution to the configured TaskExecutor and returns to the caller. This proxy-based mechanism is why self-invocation doesn't work — calling a method on 'this' bypasses the proxy.

@Async methods can return void (fire-and-forget), Future<T>, ListenableFuture<T>, or CompletableFuture<T>. CompletableFuture is the modern choice — it supports chaining, combination, timeout, and exception handling in a rich API. Void returns are appropriate for truly fire-and-forget operations where the caller doesn't need to know if or when the task completes.

Plain-English First

Spring @Async is like handing a task to a coworker instead of doing it yourself. You say 'print these 500 pages' and walk away while they handle it. The pitfall: if you hand the task to yourself (self-invocation), you're still doing it. And if you don't set up a proper team (thread pool), Spring defaults to creating a new temporary worker for every single task — expensive and uncontrollable.

Spring's @Async annotation is one of those features that looks like magic until it silently doesn't work. You add @Async to a method, call it from another method in the same class, run the code, and... it executes synchronously. No error. No warning. The annotation did nothing. This is the self-invocation problem, and it's the #1 @Async bug I see in code reviews.

Beyond self-invocation, there's the default executor problem. Spring's default async executor (SimpleAsyncTaskExecutor) creates a new thread for every single @Async invocation. No thread pool, no bounds, no reuse. In a high-traffic service where @Async is used for notification sending, event publishing, or async logging, this default creates thousands of unbounded threads under load and brings down the JVM with OutOfMemoryError on the thread stack.

Then there's exception handling. @Async methods that return void swallow exceptions by default. You call an async method, it throws a RuntimeException, and you never find out — unless you've configured an AsyncUncaughtExceptionHandler or you're returning CompletableFuture and the caller handles the future's exception.

This article covers all of it: proper ThreadPoolTaskExecutor configuration, CompletableFuture patterns for async composition, exception handling, the proxy/self-invocation limitation, and monitoring async execution in production.

Configuring ThreadPoolTaskExecutor — Never Use the Default

The single most important @Async configuration decision is the thread pool. Spring's default SimpleAsyncTaskExecutor creates a new thread per task — no pooling, no bounds. In any production system that uses @Async for user-triggered operations (sending emails, processing events, calling external APIs), this is a disaster waiting to happen. A traffic spike creates thousands of threads, exhausts OS thread limits, and crashes the JVM.

Define a ThreadPoolTaskExecutor bean. Key parameters: corePoolSize (threads kept alive even when idle — the minimum ready capacity), maxPoolSize (maximum threads when work exceeds core pool and queue), queueCapacity (number of tasks that queue up when all core threads are busy — tasks go to the queue before new threads are created above corePoolSize), and keepAliveSeconds (how long extra threads above corePoolSize stay alive when idle).

The thread creation order is: (1) if running threads < corePoolSize, create a new thread even if idle threads exist; (2) if running threads >= corePoolSize, add to queue if queue not full; (3) if queue full and running threads < maxPoolSize, create new thread; (4) if queue full and running threads == maxPoolSize, call RejectedExecutionHandler. This means maxPoolSize only helps when the queue is full — set queueCapacity based on how much backpressure is acceptable before you start creating extra threads.

Thread naming is critical for debugging. Set setThreadNamePrefix to something meaningful (e.g., 'notification-async-'). When you look at a thread dump during an incident, you can immediately see what each thread is doing. Threads named 'async-executor-5' vs 'notification-async-5' vs 'report-async-5' tell you exactly which executor is busy.

For multiple types of async work with different SLAs, configure multiple executors: one for fast user-facing async operations (small queue, fewer threads, high priority), one for background batch work (large queue, more threads, lower priority). Use @Async('notificationExecutor') to specify which executor each method uses.

CallerRunsPolicy vs AbortPolicy — choose deliberately
CallerRunsPolicy runs rejected tasks on the calling thread — this slows the caller but prevents data loss. AbortPolicy throws RejectedExecutionException — fast but drops work. DiscardPolicy silently drops tasks — dangerous. For user-facing operations (emails, notifications), prefer CallerRunsPolicy or routing to a database/queue for retry. For fire-and-forget metrics, DiscardPolicy is acceptable.
Production Insight
We run two executors: a 'userFacingExecutor' with CallerRunsPolicy (never drops user-triggered tasks) and a 'backgroundExecutor' with DiscardPolicy (background tasks can be safely dropped and will re-run next scheduled cycle).
Key Takeaway
Define ThreadPoolTaskExecutor with explicit corePoolSize, maxPoolSize, and queueCapacity. Name threads for debuggability. Configure a RejectedExecutionHandler — the default AbortPolicy silently drops work.

CompletableFuture with @Async — Composing Async Operations

Returning CompletableFuture from @Async methods enables async composition — you can chain, combine, and timeout multiple async operations using the CompletableFuture API. This is vastly more powerful than returning void or bare Future.

The pattern is simple: return CompletableFuture.completedFuture(result) from your @Async method. Spring wraps the entire method execution in the CompletableFuture machinery. If the method completes normally, the returned future completes with the result. If it throws, the future completes exceptionally.

Composing multiple async calls enables parallel execution with join: fetch user data, product data, and inventory data all in parallel, then combine when all complete. Without async, these three calls are sequential. With CompletableFuture.allOf(), all three fire simultaneously and you wait for the slowest one — reducing latency from the sum of all three to the max of all three.

Exception handling in CompletableFuture chains requires explicit handling. An uncaught exception in an async method completing a CompletableFuture doesn't propagate automatically — you must call .exceptionally(), .handle(), or check future.isCompletedExceptionally(). Forgetting to handle CompletableFuture exceptions is the async equivalent of swallowing checked exceptions — it happens, it's silent, it's confusing.

Timeout handling is critical for external API calls in async methods. Use CompletableFuture.orTimeout(5, TimeUnit.SECONDS) (Java 9+) to fail the future after a timeout. Combine with a fallback using exceptionally() to return a default value when the timeout triggers. This pattern ensures async operations never block the downstream aggregation indefinitely.

Never call .join() on CompletableFuture on a virtual thread or Netty event loop thread
In reactive stacks (WebFlux/Netty), blocking with .join() or .get() on an event loop thread deadlocks. In Spring MVC (Tomcat), .join() at the controller level is acceptable — it blocks the Tomcat thread while async tasks complete. In WebFlux, return the CompletableFuture directly and let the reactive pipeline handle it.
Production Insight
The parallel product detail fetch reduced our product page API latency from 850ms (sequential) to 280ms (parallel). Three of the four calls were 200-300ms each — parallel execution made the total time the max of the four, not the sum.
Key Takeaway
CompletableFuture.allOf() + parallel @Async calls reduces API latency from sum-of-calls to max-of-calls. Always add orTimeout() to async calls that hit external services — never let async tasks block indefinitely.

The Self-Invocation Problem — @Async's Biggest Gotcha

Spring @Async uses AOP proxies, which means: if you call a @Async-annotated method from the same class (this.myAsyncMethod()), the call bypasses the proxy and executes synchronously on the calling thread. No exception, no warning — it just runs synchronously as if @Async wasn't there. This is the most common @Async bug and the one developers hit first.

To understand why: Spring wraps your bean in a proxy class. External callers call the proxy, which intercepts the call and submits it to the executor. But when your code calls this.asyncMethod(), it's calling the method directly on the 'this' reference — the raw bean, not the proxy. The proxy is never involved, so no async execution.

The fix is to extract the @Async method into a separate Spring bean and inject it. The calling class gets a reference to the proxy of the other bean, and calls to it go through the proxy correctly. This also improves code organization — async methods are usually a different concern from the synchronous logic that triggers them.

A more exotic fix (not recommended for new code) is to inject the bean into itself: @Autowired private MyService self; then call self.asyncMethod(). Spring injects the proxy, so self.asyncMethod() goes through the proxy. But this creates a self-reference that confuses Spring's circular dependency detection and makes code harder to understand.

In Spring AOP's default mode (proxy-based), this limitation applies to all AOP features: @Transactional, @Cacheable, @Async, @Retryable. They all fail on self-invocation. The only way to bypass this limitation is to use AspectJ weaving (compile-time or load-time), which weaves advice directly into the bytecode rather than using proxies — then self-invocation works. But AspectJ weaving has its own complexity and most teams don't need it.

Self-invocation silently breaks @Async, @Transactional, @Cacheable
Any Spring AOP feature (not just @Async) fails silently on self-invocation. The method runs, but without the AOP behavior. This is a Spring AOP proxy limitation. The fix is always the same: extract the annotated method to a separate bean and inject it. This also improves design — async notification logic doesn't belong in your order processing service.
Production Insight
Add a thread name assertion in integration tests for your @Async methods: verify the method runs on a thread whose name starts with your executor prefix. This test catches self-invocation bugs immediately.
Key Takeaway
Self-invocation (this.asyncMethod()) bypasses the AOP proxy — @Async is ignored. Extract @Async methods to separate beans and inject them. Test by asserting the method runs on a thread from the async thread pool.

Exception Handling in Async Methods

Exception handling in async methods is fundamentally different from synchronous exception handling, and getting it wrong means silent failures. The calling thread and the async execution thread are separate — exceptions thrown in the async thread don't propagate to the calling thread unless you explicitly design for it.

For void @Async methods, exceptions are passed to the AsyncUncaughtExceptionHandler. If you haven't configured one, Spring uses a default handler that logs the exception at ERROR level — so it appears in logs, but there's no alerting, no retry, no fallback. Configure a custom handler in your AsyncConfigurer to route exceptions to your monitoring system, add context (which method, which arguments), and optionally trigger retry logic.

For CompletableFuture @Async methods, exceptions complete the future exceptionally. The caller must handle exceptional completion using .exceptionally(ex -> fallback), .handle((result, ex) -> ...), or by catching ExecutionException when calling .get(). If the caller never checks the future, the exception is silently swallowed — just as bad as the void case. Always chain .exceptionally() or .handle() on CompletableFutures returned from @Async methods.

For async operations that must not fail silently — order processing, financial transactions, data synchronization — wrap the async execution in a try-catch and persist failures to a database table for retry. An async job that fails should leave a record of its failure so the operation can be retried or investigated. This is the foundation of an outbox pattern.

Transaction boundaries in async methods are independent from the caller. Each @Async method execution starts fresh — no transaction from the calling thread is available. If the async method needs a transaction, it gets its own (annotate the @Async method with @Transactional — this works because the async method is called through a proxy in the executor thread). Don't expect to share a transaction across thread boundaries.

Void @Async exceptions are logged but not monitored by default
Without a custom AsyncUncaughtExceptionHandler, exceptions from void @Async methods appear in logs with no structured metadata. You won't know which order caused the email failure, what the error was, or how to retry it. Always configure a handler that captures method name, parameters, and routes to your monitoring system.
Production Insight
We persist every async job failure to a 'async_job_failures' table with method name, serialized arguments, and stack trace. A separate scheduler retries failures up to 3 times with exponential backoff. This ensures no async operation silently disappears.
Key Takeaway
Configure AsyncUncaughtExceptionHandler for void @Async methods. Chain .exceptionally() on all CompletableFuture results. Persist failures for retry — async failures should never be silent.

Async with Spring Security — Propagating SecurityContext

Spring Security stores the authenticated user in SecurityContextHolder using a ThreadLocal. When @Async spawns a new thread, that thread doesn't inherit the calling thread's ThreadLocal — so SecurityContext is null in the async method. Calling SecurityContextHolder.getContext().getAuthentication() in an @Async method returns null, causing NullPointerExceptions or authorization failures.

The fix is to configure SecurityContext propagation. Spring Security provides DelegatingSecurityContextRunnable and DelegatingSecurityContextExecutor to wrap executors with SecurityContext propagation. Or you can configure the SecurityContextHolder's strategy to use InheritableThreadLocal (which propagates to child threads) — but this doesn't work with thread pools where threads aren't child threads of the caller.

The cleanest solution is to wrap your ThreadPoolTaskExecutor with DelegatingSecurityContextExecutor. Every task submitted to this executor will have the SecurityContext copied from the submitting thread. The async method can call SecurityContextHolder.getContext() and get the authenticated user.

Alternatively, pass the authentication explicitly as a method parameter: retrieve it in the calling thread (where it's available) and pass it to the @Async method. The async method then calls SecurityContextHolder.getContext().setAuthentication(auth) at the start. This is more explicit but requires changing method signatures.

DelegatingSecurityContextExecutor propagates SecurityContext to async threads
Wrap your ThreadPoolTaskExecutor with DelegatingSecurityContextExecutor. This copies the SecurityContext from the calling thread to each task submitted to the pool. The propagation is a copy — changes in the async thread don't affect the calling thread's context.
Production Insight
We hit this in our audit logging service — all async audit logs were recording 'anonymous' as the user because SecurityContext wasn't propagated. DelegatingSecurityContextExecutor fixed it with a single line change in the executor configuration.
Key Takeaway
ThreadLocal (including SecurityContext) doesn't propagate to async threads. Use DelegatingSecurityContextExecutor to propagate SecurityContext automatically, or pass the Authentication explicitly as a method parameter.

Virtual Threads and @Async in Spring Boot 3.2+

Java 21's virtual threads (Project Loom) change the async programming landscape significantly. Virtual threads are cheap to create — you can create millions of them — and they don't block OS threads when they do I/O. This changes the calculus for @Async: if virtual threads are your executor, you no longer need to carefully tune pool sizes for I/O-bound async tasks.

Spring Boot 3.2+ supports virtual thread executors for @Async. Enable them by setting spring.threads.virtual.enabled=true in application.properties. Spring replaces platform thread executors with virtual thread executors for various components, including the async executor. For I/O-bound async tasks (HTTP calls, database queries, file I/O), virtual threads eliminate the need for careful pool sizing — just fire as many as you need.

However, virtual threads have limitations. CPU-bound tasks don't benefit — virtual threads still pin to OS threads for CPU work, and creating too many CPU-bound virtual threads causes contention. Synchronized blocks pin virtual threads to OS threads, causing the performance issues that virtual threads were meant to avoid — Spring Boot's virtual thread documentation warns about this specifically in relation to JDBC drivers and some legacy code.

In practice, the @Async + virtual threads combination is most powerful for: fan-out API aggregation (call 10 services in parallel), notification sending (each email is a separate virtual thread), and high-concurrency web request handling. For CPU-intensive async work (report generation, PDF rendering, image processing), platform thread pools with carefully tuned sizes remain the right choice.

Mixing virtual and platform threads in the same application is valid: use virtual threads for @Async methods that do I/O, and a dedicated platform thread pool for CPU-bound work. Configure multiple executors with different strategies and select them with @Async('specificExecutor').

Virtual threads eliminate pool sizing for I/O-bound async tasks
With spring.threads.virtual.enabled=true (Spring Boot 3.2+ / Java 21), you don't need to tune thread pool sizes for I/O-bound async tasks. Each virtual thread task gets its own lightweight thread. Keep platform thread pools for CPU-bound work — virtual threads that run CPU-heavy code still pin OS threads.
Production Insight
After enabling virtual threads for our API aggregation service, we eliminated all timeout errors during traffic spikes that were previously caused by platform thread pool exhaustion. The fan-out from 8 concurrent service calls per request no longer needed careful pool sizing.
Key Takeaway
Java 21 virtual threads (spring.threads.virtual.enabled=true) make I/O-bound @Async simpler — no pool sizing needed. Keep platform thread pools for CPU-intensive async work to avoid OS thread pinning.

Enable Async Support — Don't Just Slap @EnableAsync on Anything

You think adding @EnableAsync to your main config class is enough? It is, if you trust default thread pools. I don't. The real fight starts here. @EnableAsync tells Spring to create a proxy around beans with @Async methods. By default, it uses proxy mode with JDK proxies (interface-based). If your bean doesn't implement an interface, you get a runtime error. Set proxyTargetClass=true to force CGLIB. Also, the default executor is SimpleAsyncTaskExecutor — it creates a new thread for every call. No pooling. No limits. In production, that's a recipe for thread starvation and OOM. Always pair @EnableAsync with a custom ThreadPoolTaskExecutor bean. Specify core pool size, max pool size, queue capacity, and a thread name prefix you can grep in logs. Your ops team will thank you when debugging a production incident at 2 AM.

AsyncConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — java tutorial
@Configuration
@EnableAsync(proxyTargetClass = true)
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(25);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(new CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}
Production Trap:
SimpleAsyncTaskExecutor is the default. It creates unbounded threads. In a burst of 10,000 requests, your app dies. Always provide a custom executor.
Key Takeaway
Always define a custom ThreadPoolTaskExecutor when enabling @Async — never rely on defaults.

Merging Responses from Two @Async Services — Without Blocking

You fired off two async calls. Now you need both results. The junior way? Call get() on both futures sequentially. That kills the whole point of async — you just blocked the main thread twice. Instead, use CompletableFuture.thenCombine(). It takes two futures and a BiFunction. When both complete (potentially on different threads), it merges the results without blocking the calling thread. This is where CompletableFuture shines over raw Future. Pair @Async with CompletableFuture and chain operations declaratively. Your code stays clean, your thread pool stays busy, and your response time is the max of the two services, not their sum. In Spring Boot 3.x, this works seamlessly with virtual threads too, since the composition is non-blocking.

UserOrderService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge — java tutorial
@Service
public class UserOrderService {

    @Async
    public CompletableFuture<User> getUserAsync(Long userId) {
        // simulate fetch
        return CompletableFuture.completedFuture(new User(userId, "Alice"));
    }

    @Async
    public CompletableFuture<List<Order>> getOrdersAsync(Long userId) {
        // simulate fetch
        return CompletableFuture.completedFuture(List.of(new Order(100, "Laptop")));
    }

    public CompletableFuture<UserWithOrders> getCombined(Long userId) {
        CompletableFuture<User> userFuture = getUserAsync(userId);
        CompletableFuture<List<Order>> ordersFuture = getOrdersAsync(userId);
        return userFuture.thenCombine(ordersFuture, UserWithOrders::new);
    }
}

record UserWithOrders(User user, List<Order> orders) {}
record User(Long id, String name) {}
record Order(Long orderId, String item) {}
Performance Insight:
Using thenCombine() keeps both async calls running concurrently. Total time = max(serviceA, serviceB), not sum.
Key Takeaway
Don't block with get() — compose async results with thenCombine() to keep threads working.
● Production incidentPOST-MORTEMseverity: high

SimpleAsyncTaskExecutor Creates 10,000 Threads Under Load

Symptom
During a flash sale event, the JVM crashed with 'java.lang.OutOfMemoryError: unable to create native thread'. Thread dump showed 9,847 threads all named 'SimpleAsyncTaskExecutor-N'. The @Async email notification service had been firing a new thread per order confirmation.
Assumption
The developer saw 'Executor' in the name and assumed Spring was reusing threads like a thread pool.
Root cause
SimpleAsyncTaskExecutor (Spring's default) creates a new thread for every task submission. It is NOT a thread pool. During a 10,000-order spike in 30 seconds, 10,000 threads were created simultaneously, exhausting the OS thread limit.
Fix
Added a ThreadPoolTaskExecutor bean named 'taskExecutor' with corePoolSize=10, maxPoolSize=50, queueCapacity=500. Added a RejectedExecutionHandler that logs and discards tasks when the queue is full (email confirmations can be retried from a queue if needed). Configured the queue as a bounded priority queue for order confirmations over marketing emails.
Key lesson
  • NEVER use the default SimpleAsyncTaskExecutor in production.
  • Always define a ThreadPoolTaskExecutor with explicit bounds.
  • The default exists for testing convenience, not production use.
Production debug guideSymptom → root cause → fix for common @Async failures4 entries
Symptom · 01
@Async method executes synchronously (runs on caller's thread)
Fix
Self-invocation is the most likely cause — the @Async method is called from another method in the same class. Verify by logging Thread.currentThread().getName() in the @Async method and comparing to the caller's thread name. If same, it's self-invocation. Fix: move the @Async method to a separate @Service bean and inject it. Also verify @EnableAsync is present on a @Configuration class — without it, @Async is silently ignored.
Symptom · 02
Async exceptions are silently swallowed — no log, no alert
Fix
Void @Async methods that throw exceptions log nothing by default unless you configure AsyncUncaughtExceptionHandler. Check if your @Async methods return void (vs CompletableFuture). For void methods, implement AsyncConfigurer and override getAsyncUncaughtExceptionHandler() to return a handler that logs the exception. For CompletableFuture methods, ensure callers handle exceptional completion with exceptionally() or handle().
Symptom · 03
Thread pool exhausted — tasks queuing or being rejected
Fix
Check executor metrics: curl http://localhost:8080/actuator/metrics/executor.active and executor.queue.size. If active == maxPoolSize, the pool is saturated. Check executor.rejected for task rejection count. Either increase maxPoolSize, reduce task execution time, or increase queueCapacity (but be careful — large queues hide backpressure). Add a RejectedExecutionHandler that logs rejections so you know when the queue is full.
Symptom · 04
@Async on a @Transactional method — transaction not available in async context
Fix
When a @Transactional @Async method runs on a new thread from the executor, there is no transaction context from the calling thread. Each async execution starts a new transaction (or runs without one if propagation is SUPPORTS/NOT_SUPPORTED). This is usually correct — async operations should have their own transactions. If you're expecting the caller's transaction to be active in the async method, that's wrong by design — transactions are thread-bound.
★ Async Debug Cheat SheetCommands to investigate @Async thread pool and execution issues
Check thread pool utilization
Immediate action
Query Micrometer executor metrics
Commands
curl -s 'http://localhost:8080/actuator/metrics/executor.pool.size?tag=name:taskExecutor' | jq '.measurements'
curl -s 'http://localhost:8080/actuator/metrics/executor.active?tag=name:taskExecutor' | jq '.measurements[0].value'
Fix now
If active approaches maxPoolSize, increase pool or reduce task duration. If queue.size is growing, backpressure is building
Verify @Async is actually running on a different thread+
Immediate action
Add thread name logging to the async method
Commands
grep -r '@Async' src/main/java | head -10
curl -s http://localhost:8080/actuator/threaddump | jq '[.threads[] | select(.threadName | contains("async")) | {name: .threadName, state: .threadState}]'
Fix now
If no 'async' threads appear, @EnableAsync may be missing or self-invocation is happening
Identify rejected tasks (queue full)+
Immediate action
Check rejection counter
Commands
curl -s 'http://localhost:8080/actuator/metrics/executor.rejected?tag=name:taskExecutor' | jq '.measurements[0].value'
grep -r 'RejectedExecution\|Task rejected\|queue capacity' /var/log/app.log | tail -20
Fix now
Increase queueCapacity or maxPoolSize, or add a RejectedExecutionHandler that routes to a fallback queue
@Async Return Type Comparison
Return TypeUse CaseException HandlingCompose/Chain
voidFire-and-forget, no result neededAsyncUncaughtExceptionHandler (easy to miss)❌ Cannot chain
Future<T>Legacy (pre-Java 8)catch ExecutionException on .get()Limited
CompletableFuture<T>Modern async — compose and chain.exceptionally() .handle() .orTimeout()✅ Full CompletableFuture API
ListenableFuture<T>Deprecated in Spring 6addCallback()Limited — use CompletableFuture

Key takeaways

1
Self-invocation (this.asyncMethod()) bypasses the AOP proxy
@Async is silently ignored. Extract @Async methods to a separate Spring bean.
2
Never use the default SimpleAsyncTaskExecutor in production
it creates a new thread per task with no bounds. Always configure ThreadPoolTaskExecutor with explicit pool sizes.
3
CompletableFuture return type enables async composition, timeout handling, and exception chaining. Void return requires AsyncUncaughtExceptionHandler for exception handling.
4
SecurityContext (ThreadLocal) doesn't propagate to async threads. Wrap your executor with DelegatingSecurityContextExecutor or pass Authentication explicitly.
5
Java 21 virtual threads with spring.threads.virtual.enabled=true eliminate pool sizing concerns for I/O-bound async tasks. Keep platform thread pools for CPU-bound work.

Common mistakes to avoid

6 patterns
×

Calling @Async methods from the same class (self-invocation)

Symptom
@Async method runs synchronously — no error, method runs on the calling thread
Fix
Extract @Async methods to a separate @Service class and inject it. Call the method via the injected bean reference, not 'this'
×

Using default SimpleAsyncTaskExecutor in production

Symptom
OutOfMemoryError or extreme thread count during traffic spikes — SimpleAsyncTaskExecutor creates a new thread per task
Fix
Define a ThreadPoolTaskExecutor bean with explicit corePoolSize, maxPoolSize, and queueCapacity
×

Not configuring AsyncUncaughtExceptionHandler for void @Async methods

Symptom
Async operations fail silently — logged at ERROR level but no alerting, no retry, no audit trail
Fix
Implement AsyncConfigurer.getAsyncUncaughtExceptionHandler() to route exceptions to monitoring and persist failures for retry
×

Not handling CompletableFuture exceptional completion

Symptom
Async exceptions swallowed silently — caller never knows the operation failed
Fix
Always chain .exceptionally() or .handle() on CompletableFutures returned from @Async methods. Log and route failures appropriately
×

Expecting SecurityContext in @Async methods without propagation

Symptom
SecurityContextHolder.getContext().getAuthentication() returns null in async methods — NPE or 'anonymous' user in audit logs
Fix
Wrap ThreadPoolTaskExecutor with DelegatingSecurityContextExecutor, or pass Authentication explicitly as a method parameter
×

Missing @EnableAsync annotation

Symptom
@Async annotations are silently ignored — methods run synchronously without any error
Fix
Add @EnableAsync to a @Configuration class in the application context
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Why doesn't @Async work when called from the same class?
Q02JUNIOR
What's wrong with Spring's default async executor (SimpleAsyncTaskExecut...
Q03SENIOR
How do you handle exceptions from a void @Async method?
Q04SENIOR
What's the difference between @Async and submitting directly to an Execu...
Q05SENIOR
How does SecurityContext propagation work with @Async?
Q06SENIOR
How do you aggregate results from multiple parallel @Async calls?
Q07SENIOR
How do virtual threads (Java 21) change the @Async model?
Q08SENIOR
What are the pitfalls of @Async + @Transactional on the same method?
Q01 of 08SENIOR

Why doesn't @Async work when called from the same class?

ANSWER
Spring @Async uses AOP proxies. External calls go through the proxy, which intercepts and submits to the async executor. Self-invocation (this.asyncMethod()) bypasses the proxy — it calls the raw bean directly. No proxy interception = no async execution. Fix: extract @Async methods to a separate @Service bean and inject it.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I use @Async with reactive WebFlux?
02
Does @Async propagate MDC (logging context) to the async thread?
03
How do I wait for all async tasks to complete before proceeding?
04
Can I cancel a running @Async task?
05
Why is my @Async method running slower than the synchronous version?
🔥

That's Spring Boot. Mark it forged?

10 min read · try the examples if you haven't

Previous
Scheduling Tasks with @Scheduled
19 / 21 · Spring Boot
Next
File Upload in Spring Boot