Skip to main content

@Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap

@Async is a proxy, and every surprising thing it does follows from that. Measured on Spring Boot 4.1.1 and JDK 25: the self-invocation trap, the IllegalArgumentException a plain return type throws, why max-size does nothing until queue-capacity is bounded, two Executor beans leaving @Async on an unpooled SimpleAsyncTaskExecutor, the new spring.task.execution.propagate-context property, and JEP 491 measured at 4806 ms on JDK 21 against 301 ms on JDK 25.

There is a particular kind of bug that survives for years. The code is annotated, the annotation is spelled correctly, the method returns a future, the tests pass, and the work runs on the caller’s thread anyway. Nothing is logged. Response times are what they always were, so nobody looks. @Async is not a keyword and it is not a thread. It is a marker that a bean post-processor looks for while the context is being built, and when it finds one it wraps the bean in a proxy. Every unexpected thing @Async does — and there are more of them than the reference guide suggests — follows from that one sentence. This article is the result of writing a companion project where every claim is asserted by a test and captured to a file. The measurement is nearly always the same: the name of the thread that ran the method body, returned by the method itself. Timing cannot distinguish a fast synchronous call from an asynchronous one, which is exactly why these bugs last.
If you want…Read
what the annotation actually does, and the smallest version that worksPart 1
why your method ran on the caller’s thread, and which executor picked it upPart 2
pool sizing, context propagation, and virtual threadsPart 3
Versions. Everything below was run on JDK 25.0.4.1+1 (Temurin), Spring Boot 4.1.1 and Spring Framework 7.0.9. Boot 4.1.1 was published to Maven Central on 20 August 2026; the version numbers come from maven-metadata.xml and from spring-boot-dependencies-4.1.1.pom, not from release announcements. The virtual-thread comparison also uses JDK 21.0.12.1+1. The companion project is asmhatre/spring-async-demo, module async/.

Part 1 — What the annotation actually does

The proxy’s version of your method does not call your code. It wraps your code in a Callable, hands that to an AsyncTaskExecutor, and returns immediately.
Where the proxy is, and where it is not another beanholds the proxy CGLIB proxyService$$SpringCGLIB$$0 submit(Callable) AsyncTaskExecutor target bean @Async annotated() outer() this.annotated() — the proxy is never consulted Calls that arrive from outside go through the proxy and are handed to an executor. Calls that start inside the class are plain this.method() invocations on the target object. Nothing distinguishes the two at the call site, and nothing is logged when the second happens.
Three consequences follow, and between them they explain almost every @Async question ever asked. A call that does not go through the proxy is not asynchronous. The return value has to be produced before your code runs, so the return type is constrained and exceptions have nowhere obvious to go. And which executor receives the Callable is resolved separately, by type and then by name — not necessarily the one you configured.

@EnableAsync is not automatic

Spring Boot auto-configures the executor. It does not enable the annotation.
@SpringBootApplication
@EnableAsync
public class AsyncDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(AsyncDemoApplication.class, args);
    }
}
Without @EnableAsync somewhere in the context, no post-processor is installed, no proxy is created, and every @Async method in the application runs on its caller’s thread. There is no warning at any level, because from Spring’s point of view nothing unusual has happened: you have a bean with an annotation nobody asked it to process.
The measurement, not the assertion. Every transcript in this article comes from a method that returns Thread.currentThread().getName() and whether it is virtual. Asserting “the executor was called” with a mock tells you what you already believe. Printing the thread name tells you what happened.

The smallest working version

@Service
public class SelfInvocationService {

    @Async
    public CompletableFuture<String> annotated() {
        return CompletableFuture.completedFuture(Threads.describe());
    }
}
Called from another bean, that returns task-5 (virtual=false). On a stock Spring Boot 4.1.1 context the executor behind that name is:
Executor beans in this context: 1
  applicationTaskExecutor  ->  org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
        corePoolSize=8 maxPoolSize=2147483647 queueCapacity=2147483647 threadNamePrefix=task-
Bean named 'taskExecutor' present: false
Bean named 'applicationTaskExecutor' present: true
Those numbers are read off the live bean, not from the documentation. Remember the two 2147483647s; they matter in Part 3.

Part 2 — Why it ran on the caller’s thread

The self-invocation trap

The proxy wraps the bean. It does not replace this.
@Async
public CompletableFuture<String> annotated() { ... }

public CompletableFuture<String> viaSelfInvocation() {
    return annotated();          // this.annotated() -- the proxy is not involved
}
Measured:
caller thread                : main (virtual=false)
service.annotated()          : task-5 (virtual=false)
service.viaSelfInvocation()  : main (virtual=false)
service.viaSelfReference()   : task-6 (virtual=false)
The middle line is the bug. The method returned a valid, already-completed future with the correct value in it. The only symptom is that the caller waited. The fix that survives a refactor is to move the method to another bean: if work is asynchronous it belongs behind a boundary, and a boundary is a good place for a class. If it has to stay, go back out through the proxy deliberately:
private final ObjectProvider<SelfInvocationService> self;

public CompletableFuture<String> viaSelfReference() {
    return self.getObject().annotated();
}
ObjectProvider is lazy, so there is no circular dependency to resolve at construction time. Injecting the type directly also works — Spring special-cases self-references — but it looks like a bug to every reviewer who has not read this section.
Why this keeps happening to teams who know about proxies. The calling code usually did not start out in the same class. A method gets extracted, a controller’s logic moves into the service that already had the @Async method, and one day a call that used to arrive from outside arrives from inside. Nothing fails. The throughput just changes, in a commit whose diff mentions neither threads nor annotations.

What the proxy cannot see

Spring Boot proxies with CGLIB, so the proxy is a generated subclass. A subclass can override public and protected methods. It cannot override a final one and cannot see a private one, and @Async on either is inert.
proxy class                        : com.ankurm.async.VisibilityService$$SpringCGLIB$$0
isCglibProxy                       : true
publicMethod()                     : task-3 (virtual=false)
finalMethod()                      : main (virtual=false)
protectedMethod() via the proxy    : task-4 (virtual=false)
callProtectedInternally()          : main (virtual=false)
Two lines in that transcript look identical and are not. finalMethod() ran on main because CGLIB could not override it, and calling it from another bean would not help. callProtectedInternally() ran on main because it is a self-invocation — protectedMethod() is perfectly proxyable, which the line above it proves. Same symptom, different cause, different fix. A final class is the safe version of this problem: CGLIB cannot proxy it at all, so context startup fails loudly. Kotlin developers get that case by default, since Kotlin classes are final unless declared open.

Return types, and where the exceptions go

The proxy has to return something before your method has run, which limits what it can return. AsyncExecutionAspectSupport.doSubmit enumerates the cases:
Declared return typeWhat the caller gets
CompletableFuture<T>the future from submitCompletable(task)
Future<T>the future from submit(task)
void (or Kotlin Unit)null, after submitting
anything elsean IllegalArgumentException, thrown at the call site
That last row is worth stating plainly, because a great deal of writing on @Async claims such a method “returns null”. It does not:
plainString() : java.lang.IllegalArgumentException: Invalid return type for async method
                (only Future and void supported): class java.lang.String
The method body never runs. Nothing rejects the signature at startup, so this is a runtime failure on whichever code path reaches it first — which may be a rarely exercised one. I expected null while writing the companion project, and the test told me otherwise. An exception from a future-returning method is delivered through the future, and the caller sees it only if the caller looks. An exception from a void method has nowhere to go:
ERROR ... o.s.a.i.SimpleAsyncUncaughtExceptionHandler :
  Unexpected exception occurred invoking async method:
  public void com.ankurm.async.ReturnTypeService.voidThatThrows()
java.lang.IllegalStateException: thrown from a void @Async method
That log line is the entire error handling of a void @Async method in a stock application. It increments no metric, fails no health check, and never reaches the caller. Return CompletableFuture<T> even when the value is ignored, and make sure something eventually calls whenComplete.

Which executor actually runs it

Two independent decisions are involved, and conflating them is where the surprise comes from. Does Boot create applicationTaskExecutor? TaskExecutorConfigurations gates it on OnExecutorCondition, an AnyNestedCondition whose arms are “there is no Executor bean” and “spring.task.execution.mode is force”. So any Executor bean of your own removes Boot’s, along with its properties and its thread-name prefix. Which executor does @Async resolve? AsyncExecutionAspectSupport asks for a unique bean of type TaskExecutor; failing that, a bean named exactly taskExecutor; failing that, it falls back to a plain SimpleAsyncTaskExecutor.
ContextExecutor beans@Async ran on
stockapplicationTaskExecutortask-1
one custom ExecutormyExecutormine-1
two custom ExecutorsreportsExecutor, emailsExecutorSimpleAsyncTaskExecutor-1
The third row is the one that hurts. Neither of your carefully sized two-thread pools is used. SimpleAsyncTaskExecutor starts a brand new platform thread for every single call and has no bound, so a service that was throttled to two concurrent report generations is now unthrottled, and the symptom is thread exhaustion under load rather than anything at the point of the change. It is not entirely silent. The interceptor logs it once, at INFO, during startup:
o.s.a.i.AnnotationAsyncExecutionInterceptor : More than one TaskExecutor bean found within
the context, and none is named 'taskExecutor'. Mark one of them as primary or name it
'taskExecutor' (possibly as an alias) in order to use it for async processing:
[reportsExecutor, emailsExecutor]
Three ways out, in order of preference. @Async("reportsExecutor") names the executor at each call site and survives someone adding a third one later — the right answer when the executors genuinely differ in purpose. spring.task.execution.mode=force makes Boot create applicationTaskExecutor alongside yours, which is right when your extra beans exist for something other than @Async. Naming one of them taskExecutor works and reads like an accident.

Part 3 — Pools, context, and virtual threads

Why max-size usually does nothing

Remember the stock numbers: corePoolSize=8 maxPoolSize=2147483647 queueCapacity=2147483647. ThreadPoolTaskExecutor delegates to java.util.concurrent.ThreadPoolExecutor, whose growth rule is the part people misremember.
What ThreadPoolExecutor does with a submitted task new tasksubmit() fewer than core?create a thread else: offer to queuequeued, no new thread only if the queue REFUSES create a threadup to max-size An unbounded queue never refuses, so the dashed arm is unreachable and max-size is decoration. core-size=4 max-size=12 16 blocking tasks -> 4 threads core-size=4 max-size=12 queue-capacity=4 16 blocking tasks -> 12 threads Same pool, one extra property, three times the concurrency.
Both of those last two lines are measured runs of the same sixteen blocking tasks. Raising max-size alone — the usual response to a slow async pipeline — changes nothing at all. Set queue-capacity deliberately: it is the backpressure boundary, and unbounded means an incident consists of a heap filling up rather than tasks being rejected, which is the worse of the two because it takes the whole process with it. And decide what rejection means: the default policy is AbortPolicy, so a full queue and a full pool produce RejectedExecutionException synchronously, in whichever thread called the @Async method.

spring.task.execution.propagate-context, new in Boot 4.1

Handing a Callable to another thread leaves every ThreadLocal behind. That is why the async thread sees no MDC entries and no RequestAttributes, and why “the trace id disappears in the async part” is such a common report.
spring:
  task:
    execution:
      propagate-context: true
It decorates the auto-configured executor with ContextPropagatingTaskDecorator, which snapshots everything registered with Micrometer’s ContextRegistry at submission time and restores it around the task. How new is it, exactly? New in 4.1.0. The accessor pair getPropagateContext()/setPropagateContext(boolean) is absent from TaskExecutionProperties in 4.0.8 and in 3.5.9, and present in 4.1.0 — checked by extracting that one class from each spring-boot-autoconfigure jar and running javap over it, rather than by reading release notes. Note also what it is not: there is no matching spring.task.scheduling.propagate-context.
Three ways this property silently does nothing. Micrometer’s context-propagation is not on the classpath — the property still binds and is still accepted, and nothing decorates the executor. Or nothing registered a ThreadLocalAccessor, so the snapshot is empty; a ThreadLocal of your own does not register itself. Or the executor is not the auto-configured one, because you declared your own Executor bean and opted out of everything Boot was doing.

Virtual threads, and the pinning advice that expired

With spring.threads.virtual.enabled=true, applicationTaskExecutor becomes a SimpleAsyncTaskExecutor over virtual threads:
applicationTaskExecutor : org.springframework.core.task.SimpleAsyncTaskExecutor
@Async ran on           : task-1 (virtual=true)
The thread-name prefix is unchanged, so task-1 alone does not tell you which world you are in; Thread.currentThread().isVirtual() does. The pool properties are still bound and now mean nothing — Boot’s own metadata says “Doesn’t have an effect if virtual threads are enabled” for each of core-size, max-size, queue-capacity and keep-alive. More importantly, a SimpleAsyncTaskExecutor has no queue and, by default, no concurrency limit at all. Cheap threads are not free downstream capacity: ten thousand concurrent calls to a service with a twenty-connection pool is ten thousand threads queueing on a semaphore. spring.task.execution.simple.concurrency-limit is the bound. Now the advice. Nearly everything written about virtual threads before 2025 tells you to avoid synchronized, because a virtual thread that blocks while holding a monitor pins its carrier. JEP 491, delivered in JDK 24, removed that. The probe runs 32 virtual threads, each sleeping 300 ms, with the scheduler limited to two carrier threads — once with the sleep inside a synchronized block on an uncontended private monitor, once without. If pinning happens, the guarded run must take about 32 / 2 × 300 ms = 4800 ms. The same class file, compiled with --release 21, run on both JVMs:
java.version : 21.0.12.1
no monitor held            :   313 ms
blocked inside synchronized:  4806 ms

java.version : 25.0.4.1
no monitor held            :   312 ms
blocked inside synchronized:   301 ms
4806 ms against the 4800 ms the arithmetic predicts, then the whole effect gone.
What has not changed. A virtual thread still pins its carrier while executing a native frame or inside a class initialiser. And synchronized is still a mutual-exclusion lock, so a contended monitor still serialises your work — JEP 491 removed the carrier-thread cost, not the lock. ReentrantLock remains preferable where you want fairness, timeouts or tryLock; it is no longer required merely to avoid pinning.

And if you are on JDK 21 — still an LTS, still a perfectly reasonable place to be — the old advice is your advice, and 4806 ms is what it is protecting you from.

The long tail

Each of these has a chapter in the companion repository that reproduces it:
  • A final class fails context startup loudly; a final method fails silently — chapter 3
  • Only one AsyncConfigurer may exist, and Boot 4.1 already contributes one, so supplying your own to change the exception handler also takes over executor selection — chapter 4
  • spring.task.execution.shutdown.await-termination is false, so queued tasks are dropped on shutdown — chapter 5
  • CallerRunsPolicy makes the method synchronous under saturation, which is a design decision rather than a fallback — chapter 5
  • @EnableAsync(mode = AdviceMode.ASPECTJ) removes the proxy limitations entirely, at the price of load-time weaving — chapter 2
  • ExecutorDiagnostics prints every Executor bean with its real pool numbers — two lines that answer questions which otherwise take an afternoon — chapter 7
Should you use @Async at all? Often not. @Async is in-process fire-and-forget: if the JVM stops, the work is gone, and nothing retries it. That is fine for warming a cache and wrong for sending an email, writing an audit record or calling a payment provider. Those want a queue and a consumer, or at least a row in a table that something drains. The honest test is whether you would be comfortable losing the work on a deploy. If not, @Async is the wrong tool no matter how well you size the pool.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.