Ch. 8 · Spring Boot

Spring @Async and Executor Configuration

How @Async really dispatches work, why Boot's default pool never grows past 8 threads, and how to size, name and monitor your own executor.

~6 min readadvancedupdated Oct 19, 2026

@Async looks like the easiest performance win in Spring: annotate a method, add @EnableAsync, and the caller no longer waits. In practice it’s where a lot of production surprises come from: work that silently runs on the caller’s thread, a queue that grows to hundreds of thousands of tasks, exceptions that vanish, log lines that lose their request id. Interviewers who ask about it are usually checking whether you know what’s happening underneath the annotation.

The Java in this article targets Spring Boot 3.x on Java 17 or later. It’s illustrative and hasn’t been compiled here; the thread-pool behaviour is checked separately with a small simulation below.

What the annotation actually does

@EnableAsync registers a bean post-processor that wraps any bean with @Async methods in a proxy. When something calls the method through the proxy, the proxy packages the call as a task, hands it to an executor, and returns immediately. The method body runs later on one of the executor’s threads.

That design gives you the first two interview answers:

  • Self-invocation bypasses it. If ReportService.generateAll() calls this.generateOne(id), the call never touches the proxy, so generateOne runs synchronously on the caller’s thread, even though it’s annotated. No error, no warning. Move the async method to a separate bean, or inject the bean’s own proxy, to fix it.
  • It needs a public method on a Spring bean. Calling new ReportService().generateOne(id) gives you a plain object with no proxy at all.

Return types matter too. A void method is fire-and-forget. A method returning CompletableFuture<T> gives the caller a future that completes when the work does, including exceptionally. (The older AsyncResult class is deprecated since Spring Framework 6.0; use CompletableFuture.completedFuture(value) instead.)

@Service
public class ReportService {

    @Async("reportExecutor")
    public CompletableFuture<Report> generate(long customerId) {
        Report report = buildReport(customerId);   // slow: queries plus PDF rendering
        return CompletableFuture.completedFuture(report);
    }
}
java

The string in @Async("reportExecutor") is a bean name. Without it, Spring looks for a default executor (Boot’s auto-configured one, a bean named taskExecutor, or a single TaskExecutor bean).

Why the pool never grows: ThreadPoolExecutor’s rules

When you don’t define an executor, Spring Boot auto-configures a ThreadPoolTaskExecutor with 8 core threads, threads named task-1, task-2 and so on, and both the maximum pool size and the queue capacity effectively unlimited (Integer.MAX_VALUE). The Boot reference documentation shows spring.task.execution.pool.max-size=16 alongside queue-capacity=100, and it pairs them deliberately.

The reason is the order in which java.util.concurrent.ThreadPoolExecutor makes decisions for each new task:

  1. If fewer than corePoolSize threads exist, start a new thread.
  2. Otherwise, try to put the task on the queue.
  3. If the queue is full and fewer than maxPoolSize threads exist, start a new thread.
  4. Otherwise, reject the task.

Extra threads beyond the core are only created when the queue is full. With an unbounded queue it never is. Here’s a short Node 22 script that applies those four rules to a burst of tasks that all arrive before any finishes:

function submitBurst({ core, max, queueCapacity, tasks }) {
  let threads = 0, queued = 0, rejected = 0;
  for (let i = 0; i < tasks; i++) {
    if (threads < core) threads++;
    else if (queued < queueCapacity) queued++;
    else if (threads < max) threads++;
    else rejected++;
  }
  console.log(`core=${core} max=${max} queue=${queueCapacity} tasks=${tasks} -> threads=${threads} queued=${queued} rejected=${rejected}`);
}
submitBurst({ core: 8, max: Infinity, queueCapacity: Infinity, tasks: 500 }); // Boot's defaults
submitBurst({ core: 4, max: 16, queueCapacity: 100, tasks: 50 });
submitBurst({ core: 4, max: 16, queueCapacity: 100, tasks: 500 });
submitBurst({ core: 4, max: 16, queueCapacity: 20, tasks: 50 });
JavaScript
core=8 max=Infinity queue=Infinity tasks=500 -> threads=8 queued=492 rejected=0
core=4 max=16 queue=100 tasks=50 -> threads=4 queued=46 rejected=0
core=4 max=16 queue=100 tasks=500 -> threads=16 queued=100 rejected=384
core=4 max=16 queue=20 tasks=50 -> threads=16 queued=20 rejected=14
Text

The first line is Boot’s default: 500 tasks, still 8 threads, 492 waiting. Setting max-size=64 alone changes nothing. The second line catches people too: a max of 16 with a queue of 100 means 50 tasks run on just 4 threads, because the queue never filled. Only when the queue overflows (lines three and four) does the pool grow, and then it rejects.

An unbounded queue is also a memory and latency problem. Each queued task holds its arguments. If 2,000 report requests arrive in a minute and each takes 3 seconds on 8 threads, the last one waits over 12 minutes, and if the process restarts, the queue’s contents are lost.

A deliberately sized executor

Size an executor for the kind of work it runs. For blocking I/O like the report generator, threads mostly wait, so you can have more of them than CPU cores; for CPU-bound work, more threads than cores just adds contention. Then bound the queue by how long you’re willing to make a task wait, and decide what happens when it’s full.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("reportExecutor")
    public ThreadPoolTaskExecutor reportExecutor() {
        ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
        ex.setCorePoolSize(8);
        ex.setMaxPoolSize(8);              // fixed size: no surprise growth
        ex.setQueueCapacity(200);          // about 75 s of backlog at 3 s per report
        ex.setThreadNamePrefix("report-");
        ex.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
        ex.setTaskDecorator(new MdcTaskDecorator());
        ex.setWaitForTasksToCompleteOnShutdown(true);
        ex.setAwaitTerminationSeconds(30);
        return ex;                         // Spring calls initialize() for you
    }
}
java

AbortPolicy throws TaskRejectedException back to the caller when the queue is full, so the controller can answer 503 and the client can retry later. CallerRunsPolicy is the alternative: the submitting thread runs the task itself, which slows the producer down (a form of backpressure) but means a request thread can suddenly spend 3 seconds rendering a PDF. Pick one on purpose.

The thread name prefix pays for itself the first time you read a thread dump. And separate executors for separate workloads keep a flood of report jobs from delaying, say, outbound webhook calls.

One Boot detail to mention: declaring your own Executor bean makes the auto-configured applicationTaskExecutor back off, and Spring MVC’s async request handling, among other integrations, looks for that bean by name. If you need both, give your bean a qualifier and, on Boot 3.4 or later, declare it with @Bean(defaultCandidate = false) so the auto-configured executor stays in place.

Exceptions and context that don’t cross threads

Exceptions. When a CompletableFuture method throws, the future completes exceptionally and the caller sees it in join() or exceptionally(). When a void method throws, there’s no caller to tell. Spring passes it to an AsyncUncaughtExceptionHandler; the default just logs it at error level. Register your own through AsyncConfigurer.getAsyncUncaughtExceptionHandler() if those failures need metrics or alerts.

Thread-local context. The MDC (where your request id lives), Spring Security’s SecurityContextHolder, request-scoped beans and the current transaction are all bound to the calling thread. The async thread starts without them. That’s why async log lines lose their correlation id, and why a @Transactional caller doesn’t extend its transaction into the async method: the async work runs in its own transaction, or none, and it may run before the caller’s transaction has committed. A TaskDecorator copies what you need:

public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable task) {
        Map<String, String> context = MDC.getCopyOfContextMap();   // captured on the caller's thread
        return () -> {
            if (context != null) MDC.setContextMap(context);
            try { task.run(); } finally { MDC.clear(); }
        };
    }
}
java

For the security context, Spring Security provides DelegatingSecurityContextAsyncTaskExecutor. If you use Micrometer Tracing, Spring Framework 6.1’s ContextPropagatingTaskDecorator propagates trace context the same way.

Interview question: the email that never sent

A sign-up endpoint calls userService.register(user), which saves the user and then calls this.sendWelcomeEmail(user), annotated @Async. Sign-up takes 4 seconds because the mail server is slow, and when the mail server is down, sign-ups fail. The team thought @Async made the email non-blocking. What’s wrong, and how would you fix it properly?

Answer

sendWelcomeEmail is called with this, inside the same bean, so the call never goes through the async proxy. It runs synchronously on the request thread, which explains both symptoms: the request waits for the mail server, and a mail exception propagates up and fails the sign-up.

Moving sendWelcomeEmail into its own WelcomeMailer bean makes @Async take effect. But there are follow-ups worth raising. The email now runs on another thread, possibly before register’s transaction commits, so the mailer might look up a user that isn’t visible yet. Publishing an event and handling it with @TransactionalEventListener(phase = AFTER_COMMIT) plus @Async fixes the ordering. Give the mailer a dedicated, bounded executor so a mail outage can’t fill a shared queue, and register an uncaught-exception handler or return a future so failures are counted. If the email must not be lost on a restart, an in-memory executor isn’t enough: write an outbox row in the same transaction and let a worker send it.

Where to go next

More in Spring Boot

read ✓Spring Boot · mid

Spring JPA Optimistic Locking and Conflicts

How JPA @Version prevents lost updates in Spring Boot, what ObjectOptimisticLockingFailureException means, and when to retry or return 409.

~6 min readread →
read ✓Spring Boot · easy

@Controller vs @RestController in Spring Boot

@RestController is @Controller plus @ResponseBody: return values become the response body, not view names. See both styles and the usual bugs.

~6 min readread →
esc