Files
spring-async-demo/async
Ankur Mhatre 9b3fe88813 Add the async module
Every @Async behaviour that surprises people, asserted by a test and captured
to docs/output/: the self-invocation trap, what CGLIB cannot override, the
IllegalArgumentException a plain return type throws, the unbounded queue that
makes max-size decoration, spring.task.execution.propagate-context (new in
Boot 4.1.0), the two Executor beans that leave @Async on an unpooled
SimpleAsyncTaskExecutor, and JEP 491 measured on JDK 21 against JDK 25.
2026-09-01 23:32:16 +05:30
..
2026-09-01 23:32:16 +05:30
2026-09-01 23:32:16 +05:30
2026-09-01 23:32:16 +05:30
2026-09-01 23:32:16 +05:30
2026-09-01 23:32:16 +05:30

async

Companion project for @Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap on ankurm.com.

Everything here is asserted by a test and captured under docs/output/. The measurement is always the same: the name of the thread the method body actually ran on, returned by the method itself. Timing cannot distinguish a fast synchronous call from an asynchronous one; a thread name can.

Verified stack

Component Version Source of the number
JDK 25.0.4.1+1 (Temurin) java -version
Spring Boot 4.1.1 maven-metadata.xml on Maven Central
Spring Framework 7.0.9 spring-boot-dependencies-4.1.1.pom
micrometer context-propagation Boot-managed spring-boot-dependencies-4.1.1.pom
JDK used for the pinning comparison 21.0.12.1+1 (Temurin) java -version

Quickstart

mvn test                                     # 22 tests, all of the transcripts
scripts/run-all.sh                           # regenerates every docs/output/ file
scripts/run-all.sh /path/to/jdk21/bin/java   # adds the JDK 21 vs 25 pinning comparison

Documentation

Chapter What it settles
01 What @Async actually does The proxy is the whole mechanism, and @EnableAsync is not automatic
02 The self-invocation trap Why an internal call runs inline, and the three fixes
03 What the proxy cannot see final and private methods, and how to tell that case from a self-invocation
04 Return types and exceptions A plain return type throws; it does not return null
05 Pool sizing Why max-size does nothing until queue-capacity is bounded
06 Context propagation spring.task.execution.propagate-context, new in Boot 4.1.0
07 Which executor runs it Two Executor beans and @Async quietly uses neither
08 Virtual threads and pinning JEP 491 measured on JDK 21 against JDK 25

Captured output

File What it shows
self-invocation.txt The same method on task-5, on main, and on task-6
visibility.txt final is inert; protected is not
return-types.txt The IllegalArgumentException a plain return type throws
executor-report.txt Stock pool numbers, read off the live bean
executor-one-custom.txt Boot backing off in favour of one custom Executor
executor-two-custom.txt Two custom executors and neither is used
executor-force-mode.txt spring.task.execution.mode=force restoring it
pool-unbounded-queue.txt 16 tasks, 4 threads, max-size=12 ignored
pool-bounded-queue.txt The same 16 tasks on 12 threads
virtual-threads.txt SimpleAsyncTaskExecutor, virtual=true, pool properties inert
context-propagation.txt A ThreadLocal surviving the hop
pinning-probe.txt 4806 ms on JDK 21, 301 ms on JDK 25
tests.txt The test run behind all of the above

The one diagnostic

ExecutorDiagnostics prints every Executor bean in the context with its real core size, max size and queue capacity. It is the fastest way to answer "which executor is actually running this". Delete it before shipping.