You've probably seen this setup. A service has a public method annotated with @Async, the application has @EnableAsync, and another method in the same class calls it with this.asyncWork(), or just asyncWork(). It compiles, the test is green, and the review gets approved. But the work runs on the caller's thread, and no executor ever touches it.
The method returns a CompletableFuture and get() comes back right away, so everything looks asynchronous. The future just happened to complete on main.
By the end you'll have a Maven fixture with three tests that report which thread did the work in each case: main when the call goes through this, task-1 when it goes through the proxy, and task-2 after the method moves to a separate bean. You'll also see what that fix solves and what it doesn't.
What ran, and on which versions
The fixture ran on October 23, 2026 with Spring Boot 4.1.2 (the current GA), Spring Framework 7.0.10, Temurin 25.0.4 LTS and Maven 3.9.12. JUnit Jupiter comes from the Boot BOM with no version pinned in the POM, and this run resolved 6.0.3. Surefire is pinned at 3.6.0. The Spring Boot system requirements page says 4.1.2 needs at least Java 17, works up to and including Java 27, and requires Spring Framework 7.0.10 or later. JDK 25 is inside that range.
There's no web server and no container. The context starts with webEnvironment = NONE. mvn -q test passed, and the Surefire report records tests=3 failures=0 errors=0 skipped=0.
On dependencies, spring-boot-starter plus @EnableAsync is all you need. spring-context already depends on spring-aop 7.0.10, and that's where the @Async proxy comes from. Don't go looking for spring-boot-starter-aop on the 4.1 line. It has no 4.1.x GA release on Maven Central, so resolution fails. spring-boot-starter-aspectj does exist at 4.1.2, but it's for auto-proxying AspectJ aspects, and you don't need it for @Async in the default mode.
The contract: the proxy only intercepts what goes through it
In the default mode, @Async is built on Spring AOP. The bean the container injects isn't your class. It's a proxy that intercepts the call, hands the work to an executor and returns a Future. That detour only happens when the call comes in through the proxy.
The Spring Framework proxying documentation describes what happens once the call reaches the real object:
> "once the call has finally reached the target object (the SimplePojo reference in this case), any method calls that it may make on itself, such as this.bar() or this.foo(), are going to be invoked against the this reference, and not the proxy."
It states the consequence right after:
> "self invocation via an explicit or implicit this reference will bypass the advice."
Note the "explicit or implicit" part. Calling asyncWork() without the this. prefix is exactly the same thing.
The @EnableAsync javadoc covers @Async specifically. The mode attribute defaults to AdviceMode.PROXY, and its documentation says:
> "Local calls within the same class cannot get intercepted that way; an Async annotation on such a method within a local call will be ignored since Spring's interceptor does not even kick in for such a runtime scenario."
This is how proxy mode is documented to behave. It isn't a Boot 4.1 bug. AdviceMode.ASPECTJ would intercept local calls, but it needs AspectJ weaving. That isn't the Boot 4.1 default and it changes your whole build model, so it's out of scope here.
Two details about the proxy will help you read the fixture:
- CGLIB is the default in Boot. The Spring Boot AOP page says auto-configuration sets up CGLib proxies and that
spring.aop.proxy-target-class=falseswitches to JDK proxies. That's why a concrete class with no interface gets a generated subclass. - Use public methods. A subclass proxy can't intercept
privatemethods, and the same proxying page warns thatfinalmethods can't be advised because they can't be overridden. An@Asyncon a private method is never seen by the interceptor, even when the call comes from outside.
That gives you a review rule: with @EnableAsync in PROXY mode, this.someAsyncMethod() is a bug.
The fixture
Everything lives in the com.devdojo.asyncdemo package.
POM
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
</plugins>
</build>
Application
package com.devdojo.asyncdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
The service that calls itself
package com.devdojo.asyncdemo;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class SelfInvokingService {
public String callerThreadViaThis() {
try {
return this.asyncWork().get(Duration.ofSeconds(2).toMillis(), TimeUnit.MILLISECONDS);
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
public String callerThreadViaBean(SelfInvokingService proxy) {
try {
return proxy.asyncWork().get(Duration.ofSeconds(2).toMillis(), TimeUnit.MILLISECONDS);
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
@Async
public CompletableFuture<String> asyncWork() {
return CompletableFuture.completedFuture(Thread.currentThread().getName());
}
}
The @Async method returns the name of the thread it ran on, and the caller waits with get and a 2-second timeout. There's no Thread.sleep. The proof is the thread name, not the clock.
The two callers look alike, but they differ in one place. callerThreadViaThis() calls the method through this. callerThreadViaBean(proxy) calls it through the reference passed in as an argument, and the test passes the injected bean there, which is the proxy. Both methods are public and get called from outside, through the proxy. Neither has @Async, so the proxy just forwards the call to the real object. What matters is the next call, the one made from inside.
The three tests
package com.devdojo.asyncdemo;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class AsyncSelfInvocationTest {
@Autowired
SelfInvokingService selfInvokingService;
@Autowired
ExtractedCaller extractedCaller;
@Test
void selfInvocationRunsOnCallerThread() {
String caller = Thread.currentThread().getName();
String worker = this.selfInvokingService.callerThreadViaThis();
System.out.println("self-invocation caller=" + caller + " worker=" + worker);
assertThat(worker).isEqualTo(caller);
}
@Test
void beanInvocationRunsOnAsyncExecutor() {
assertThat(AopUtils.isAopProxy(this.selfInvokingService)).isTrue();
assertThat(AopUtils.isCglibProxy(this.selfInvokingService)).isTrue();
System.out.println("proxy class=" + this.selfInvokingService.getClass().getName());
String caller = Thread.currentThread().getName();
String worker = this.selfInvokingService.callerThreadViaBean(this.selfInvokingService);
System.out.println("bean-invocation caller=" + caller + " worker=" + worker);
assertThat(worker).isNotEqualTo(caller);
assertThat(worker).contains("task-");
}
@Test
void extractedBeanRunsOnAsyncExecutor() {
String caller = Thread.currentThread().getName();
String worker = this.extractedCaller.callerThreadViaExtractedBean();
System.out.println("extracted-bean caller=" + caller + " worker=" + worker);
assertThat(worker).isNotEqualTo(caller);
assertThat(worker).contains("task-");
}
}
The first test asserts the bug: the worker thread is the caller's thread. The other two assert the fix: the thread is different, and it comes from the executor.
What the tests recorded
| scenario | caller | worker |
|---|---|---|
this.asyncWork() inside the service | main | main |
proxy.asyncWork() through the injected bean | main | task-1 |
extracted AsyncWorker, called by ExtractedCaller | main | task-2 |
In the second test, AopUtils.isAopProxy and AopUtils.isCglibProxy both returned true, and the injected bean's class is com.devdojo.asyncdemo.SelfInvokingService$$SpringCGLIB$$0. The proxy was there all along, including in the first test. It was generated for @Async and registered in the context, and it never gets used when the call goes out through this.
The task-1 and task-2 threads come from the ThreadPoolTaskExecutor that Boot auto-configures for @EnableAsync when no AsyncConfigurer is defined. Virtual threads are off in this fixture. The task- prefix only shows that the interceptor ran. It says nothing about performance.
The fix: move @Async out of the calling class
The proxying documentation calls this the best and least invasive option: don't self-invoke. Move the async method into another bean:
package com.devdojo.asyncdemo;
import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
@Component
public class AsyncWorker {
@Async
public CompletableFuture<String> asyncWork() {
return CompletableFuture.completedFuture(Thread.currentThread().getName());
}
}
The caller gets that bean through injection:
package com.devdojo.asyncdemo;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Service;
@Service
public class ExtractedCaller {
private final AsyncWorker worker;
public ExtractedCaller(AsyncWorker worker) {
this.worker = worker;
}
public String callerThreadViaExtractedBean() {
try {
return this.worker.asyncWork().get(Duration.ofSeconds(2).toMillis(), TimeUnit.MILLISECONDS);
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}
The this.worker here is fine, because worker is another bean's proxy and the call goes in through it. The third test confirms it: caller=main worker=task-2.
The same page documents two other ways out:
- Inject the bean into itself and call the method through the injected reference, never through
this. It's a documented option, and the second test uses exactly that mechanism by passing the proxy in as an argument. It works, but now the class depends on itself. DevDojo prefers extraction. AopContext.currentProxy(), which the docs call "highly discouraged". It gets a mention here and nothing more.
Return types, and what the javadoc doesn't promise
The @Async javadoc for 7.0.10 restricts the return type:
> "the return type is constrained to either void or Future. In the latter case, you may declare the more specific CompletableFuture type"
So you get void or Future, with CompletableFuture as the more specific option. ListenableFuture isn't on that list.
The javadoc also never promises that the work runs on a different thread from the caller. When AsyncExecutionInterceptor runs, it submits the method call to the executor and returns control right away. The different thread follows from the interceptor having run. In the this case it doesn't run, so the method executes like any other Java method and returns a future that's already complete. The annotation compiles, does nothing, and nothing warns you. It's about as quiet as a failure gets.
What the fix doesn't do
Extracting the method, or calling through the proxy, only guarantees that the interceptor runs. It does not:
- add a timeout to the method's I/O;
- replace a custom
Executor, anAsyncConfigurer, or the pool configured underspring.task.execution.*; - change your virtual-thread or platform-thread policy. That's the subject of the post on virtual threads and blocking I/O, which is about how many waits the system can hold, not about who intercepts the call;
- make a
void@Asyncobservable. WithoutFuture.getor a latch, the test can pass without proving any worker thread ever existed.
It's also a different problem from AFTER_COMMIT listeners that don't fire in a @Transactional test. There, the interceptor runs and the commit is what never happens.
Next step
- List the classes that use
@Async(grep -rln "@Async" src/main/java). In each one, look for calls to the annotated method from inside the same class, withthis.or with no receiver at all. Grepping forthis.won't catch the implicit call, so you'll have to read that part yourself. - For every
@Asyncmethod that matters, write the test with both assertions:workerdiffers fromcaller, andworkercarries the executor's prefix (task-for Boot's auto-configured executor; adjust it for your project's executor). If the method returnsvoid, change it toCompletableFutureor expose a latch. Otherwise the test has nothing to check. - If you find a self-invocation, extract the method into its own
@Componentand let the test showtask-Nwhere it used to showmain.