Some work should not happen while a customer waits. Sending an email, calling a slow third party, generating a report — none of it needs to finish before the HTTP response goes out. Moving it to another thread is one annotation; sizing the pool it runs on is where the engineering is.
Turning it on
@Slf4j
@Configuration
@EnableAsync
@EnableScheduling
@EnableResilientMethods
public class ThreadPoolConfig implements AsyncConfigurer, SchedulingConfigurer { }@EnableAsync for @Async, @EnableScheduling for
@Scheduled. Without them the annotations are inert — the method just runs
synchronously, and nothing tells you.
Sizing the pool on purpose
/*
* corePoolSize parameter is the amount of core threads which will be instantiated and kept in the
* pool. If all core threads are busy and more tasks are submitted, then the pool is allowed to grow
* up to a maximumPoolSize.
*/
@Bean(name = "taskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(25);
executor.setMaxPoolSize(150);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("Pizza-API-");
executor.setAllowCoreThreadTimeOut(true);
executor.setKeepAliveSeconds(60);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setTaskDecorator(new LogTaskDecorator());
executor.initialize();
return executor;
}⚠️ The bean name is not decoration. Spring's @Async support looks for
a bean literally called taskExecutor. Name it anything else and your async work silently
runs on a default SimpleAsyncTaskExecutor — which creates a new thread per
task and has no upper bound at all. Under load that is how you exhaust memory.
⚠️ The growth rule surprises everyone
A ThreadPoolExecutor does not grow from core to max when it is busy.
It grows when the queue is full:
task arrives
│
├─ fewer than corePoolSize threads? → start a new thread
├─ queue not full? → QUEUE IT
├─ fewer than maxPoolSize threads? → start a new thread
└─ otherwise → rejection policyWith queueCapacity = 100, threads 26 through 150 are only created after 100 tasks are
already waiting. A large queue therefore means your maxPoolSize is almost never reached —
which is a reasonable design, but not the one most people think they configured.
- Small queue, large max — responsive, more threads, more context switching.
- Large queue, small max — steady thread count, tasks wait longer.
- Unbounded queue (the default with
Executors.newFixedThreadPool) —maxPoolSizeis meaningless and the queue grows until you run out of memory.
Rough starting points: for CPU-bound work, core ≈ number of cores; for I/O-bound work, much higher, because threads spend most of their time blocked. The pizza API's 25/150 is I/O-bound sizing.
The rejection policy
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());| Policy | When full |
|---|---|
AbortPolicy (default) | throws
RejectedExecutionException |
CallerRunsPolicy | the submitting thread runs it |
DiscardPolicy | silently drops it |
DiscardOldestPolicy | drops the oldest queued task |
CallerRunsPolicy is usually the right choice: it applies natural backpressure. When the
pool is saturated the caller — often a request thread — does the work itself, which slows down
request acceptance and stops the queue growing further. Nothing is lost. The two
Discard policies lose work silently, which is almost never what you want.
Using it
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendConfirmation(OrderPlacedEvent event) {
try {
mailService.sendOrderConfirmation(
orderService.getOrderByPublicId(event.orderPublicId()));
} catch (Exception ex) {
log.error("Confirmation for order {} failed — the order itself is unaffected",
event.orderPublicId(), ex);
}
}Return CompletableFuture when you need the result:
@Async
public CompletableFuture<ReportDTO> buildReport(LocalDate from) {
return CompletableFuture.completedFuture(reportService.build(from));
}
// Run three in parallel and wait for all of them
var a = reportService.buildReport(lastWeek);
var b = reportService.buildTopProducts(lastWeek);
var c = reportService.buildStatusCounts(lastWeek);
CompletableFuture.allOf(a, b, c).join();⚠️ Three things that catch people
1. An @Async void method throws away its exceptions
Nothing is watching the return value, so an exception has nowhere to go. It reaches the
AsyncUncaughtExceptionHandler and nothing else:
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, listOfObjects) -> {
log.error("****** AsyncConfig - handleUncaughtException(...) ******");
log.error("* Class name: {}", method.getDeclaringClass());
log.error("* Method name - {}", method.getName());
log.error("* Exception message - {}", ex.getMessage());
for (Object param : listOfObjects) {
log.error("* Param - {}", param);
}
};
}Configure one, or async failures are completely invisible. With a
CompletableFuture return type the exception surfaces on get() instead.
2. The proxy rule
public void process() {
sendEmail(); // runs SYNCHRONOUSLY — self-invocation, no proxy
}
@Async
public void sendEmail() { }Same as @Transactional, @Cacheable and @PreAuthorize —
lesson 8.
3. A new thread has no context
The SecurityContext, the transaction and the request scope all live in
ThreadLocals. An @Async method starts with none of them: no authenticated
user, no persistence context, and any lazy association is detached.
This is why the pizza API's events carry values rather than entities (lesson 9), and why the async listener re-reads the order rather than receiving it.
Logging context needs carrying across explicitly, which is what the pool's task decorator does:
/**
* Keep context tags to use in the new thread<br>
* For example, we are using the memberUuid in the new thread.
*/
class LogTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
runnable.run();
} finally {
MDC.clear();
}
};
}
}Without it, a trace id attached to the request thread is absent from every async log line, and correlating them becomes impossible.
Scheduled work
/** Task scheduler for @Scheduled tasks */
@Bean(name = "taskScheduler")
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setThreadNamePrefix("Pizza-API-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.initialize();
return scheduler;
}
/** Configure @Scheduled tasks to use the taskScheduler thread pool */
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(taskScheduler());
}Configuring this matters. The default scheduler is single-threaded, so one slow job delays every other one — and a job that hangs stops all scheduled work in the application, permanently.
@Scheduled(cron = "0 0 3 * * *") // 03:00 daily
@Scheduled(fixedDelay = 60000) // 60s after the previous run FINISHES
@Scheduled(fixedRate = 60000) // every 60s regardless of durationfixedDelay is the safer default: with fixedRate, a job that takes longer
than its interval starts overlapping itself.
⚠️ With multiple instances, every instance runs every scheduled job. A nightly report emailed three times is the mild version; a job that charges cards is not. You need ShedLock, a database lock, or a scheduler outside the application.
Graceful shutdown
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);Without these, a deploy kills in-flight async work mid-task. Combine with:
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30sWhat to take from this
- Name the executor bean
taskExecutor, or you get an unbounded default. - The pool grows when the queue is full, not when threads are busy.
CallerRunsPolicyfor backpressure that loses nothing.- Configure an
AsyncUncaughtExceptionHandler, or void async failures vanish. - A new thread has no security context and no transaction.
- Configure the scheduler pool — the default is one thread.
Next: retries — and why retrying a payment without an idempotency key charges the customer twice.