Hello! Welcome back to our journey into backend development.
In our last lesson, we introduced asynchronous processing with Spring's @Async annotation. We saw how it can make applications more responsive by offloading long-running tasks to background threads. However, we relied on Spring's default mechanism for managing those threads. While simple, this default is not suitable for production systems where resource control is critical.
Today, we will address that gap. You will learn how to configure a custom thread pool executor for your asynchronous operations. This will give you precise control over how many threads your application uses, how it handles load, and how it behaves when under stress. Mastering this is a key step towards building robust, scalable, and production-ready applications.
1. The Problem with the Default Executor
When you enable @Async without providing your own configuration, Spring Boot uses a SimpleAsyncTaskExecutor. As its name implies, it's simple, but it has significant drawbacks for production use:
- It creates a new thread for every single task. If you submit 1,000 tasks, it will attempt to create 1,000 threads.
- It does not reuse threads. This is inefficient and adds overhead.
- It has no limit on the number of concurrent threads. This can quickly exhaust your server's memory and CPU resources, leading to instability or crashes.
To overcome these limitations, we need to define our own thread pool.
2. Configuring a ThreadPoolTaskExecutor
The recommended solution in Spring is to use the ThreadPoolTaskExecutor. This class is a powerful Spring abstraction built on top of Java's own java.util.concurrent.ThreadPoolExecutor, which you may be familiar with from your work with core Java multithreading. It allows for fine-grained control over thread creation, reuse, and queueing.
Let's start by seeing how to define one.
Async In Spring Boot: Threading, Configuration & Rejection ...
The article 'Async In Spring Boot' provides a clear, practical example of how to set up a custom ThreadPoolTaskExecutor. We'll focus on the initial configuration.
Please review the section '✅ Recommended: Define a Custom ThreadPoolTaskExecutor'. It shows a complete configuration class with the @EnableAsync annotation and a @Bean method that creates and configures the executor. Pay attention to the methods being called on the executor object, like setCorePoolSize and setMaxPoolSize. We'll dive into what these mean next.
As you saw in the example, the configuration is done in a class annotated with @Configuration. You create a method annotated with @Bean that returns an Executor (the interface ThreadPoolTaskExecutor implements). Spring will then manage this bean and use it for asynchronous tasks.
3. Understanding the Core Parameters
The power of ThreadPoolTaskExecutor comes from its configurable properties. Understanding how they interact is crucial for tuning your application's performance. The three most important parameters are corePoolSize, maxPoolSize, and queueCapacity.

Let's break down the process shown in the diagram.
ThreadPoolTaskExecutor corePoolSize vs. maxPoolSize
The Baeldung article 'ThreadPoolTaskExecutor corePoolSize vs. maxPoolSize' offers a great explanation of these parameters and their subtle interactions.
Read sections '2. corePoolSize vs. maxPoolSize' and '3. So What’s the Difference?'. These sections define each property and, most importantly, explain the logic the executor follows when a new task is submitted. Focus on the sequence: first core threads, then the queue, and only then threads up to the max size.
To summarize the behavior:
- When a new task is submitted, if the number of running threads is less than
corePoolSize, the executor creates a new thread to handle it, even if other worker threads are idle. - If the number of running threads is equal to or greater than
corePoolSize, the task is placed into the queue. - If the queue is full, and the number of running threads is less than
maxPoolSize, the executor creates a new thread to handle the task. - If the queue is full, and the number of running threads has already reached
maxPoolSize, the task is rejected.
Another useful property is setThreadNamePrefix(). Setting a name like "EmailAsync-" makes your logs much easier to read and debug, as you can immediately identify which threads belong to which asynchronous process.
4. Handling Pool Saturation: Rejection Policies
What happens when a task is rejected? The default behavior is to throw a RejectedExecutionException, which might crash the calling thread if not handled. ThreadPoolTaskExecutor allows you to define a RejectedExecutionHandler to control this behavior.
Let's explore the common policies.
Async In Spring Boot: Threading, Configuration & Rejection ...
The 'Async In Spring Boot' article also provides an excellent summary of rejection policies.
Read the sections '💥 What If Pool + Queue Are Full?', '🔥 What is setRejectedExecutionHandler(...)?, and 'Other options:'. This will explain the default AbortPolicy and introduce you to alternatives like CallerRunsPolicy, which provides a form of backpressure by forcing the task to run on the calling thread.
Choosing a rejection policy is a design decision:
AbortPolicy(Default): Fails fast. Good for critical systems where you must know immediately if the system is overloaded.CallerRunsPolicy: Creates backpressure. The thread that submitted the task is forced to run it, slowing down the producer of tasks and giving the system time to catch up. This is a good strategy to prevent the system from being overwhelmed.DiscardPolicy: Silently drops the task. Use with extreme caution, only for non-essential tasks.DiscardOldestPolicy: Drops the oldest task in the queue to make room for the new one.
For most production systems, CallerRunsPolicy is a safe and robust choice for non-critical background work, while AbortPolicy coupled with monitoring and alerting is suitable for high-priority tasks.
5. Applying Your Custom Executor
Once you've defined your executor bean, you need to tell Spring to use it. There are two common ways to do this.
a) Specify the Executor by Name
You can pass the name of your executor bean directly to the @Async annotation. This is useful when you have multiple, specialized thread pools for different kinds of tasks.
b) Set a Default Executor
If you have one primary executor for most of your application's async tasks, specifying the name every time can be repetitive. Instead, you can configure it as the default.
Async In Spring Boot: Threading, Configuration & Rejection ...
Let's return one last time to the 'Async In Spring Boot' article to see how to set a default executor.
Read the section '✅ Optional: Set Default Executor for All @Async'. It demonstrates how your configuration class can implement the AsyncConfigurer interface to provide a global, default executor. With this in place, you can use @Async without a bean name.
Test your understanding!
You have configured a ThreadPoolTaskExecutor with the following properties:
corePoolSize = 5maxPoolSize = 10queueCapacity = 20
Your application is running, and there are currently 5 active threads processing tasks. A burst of 30 new tasks arrives simultaneously. Describe what happens to these 30 tasks. How many will be running, how many will be queued, and what will happen to the rest?
Show answer
Here's the breakdown:
-
Queuing Tasks: Since there are already 5 active threads (equal to
corePoolSize), the executor will first try to queue the new tasks. The queue has a capacity of 20, so the first 20 tasks will be added to the queue. -
Creating New Threads: The queue is now full. The remaining
30 - 20 = 10tasks need to be handled. The executor checks if it can create more threads. The current pool size is 5, andmaxPoolSizeis 10. Therefore, it will create 5 new threads to handle the next 5 tasks. The active thread count is now 10. -
Rejecting Tasks: The pool has now reached its
maxPoolSizeof 10 threads, and the queue is full. The remaining10 - 5 = 5tasks cannot be processed. They will be handed to theRejectedExecutionHandler. If the defaultAbortPolicyis in place, aRejectedExecutionExceptionwill be thrown for each of these 5 tasks.
In summary: 20 tasks will be queued, 5 will be picked up by newly created threads, and 5 will be rejected.
Conclusion
Congratulations! You've moved beyond the simple use of @Async and learned how to build and configure a production-grade, resilient asynchronous processing system. This gives you direct control over your application's resource consumption and behavior under load.
Key Takeaways:
- The default
SimpleAsyncTaskExecutoris not for production; it creates a new thread for every task without limits. ThreadPoolTaskExecutoris the recommended, highly configurable alternative.- The interplay between
corePoolSize,maxPoolSize, andqueueCapacitydetermines how the pool scales and handles incoming tasks. - Using
setThreadNamePrefixis a best practice for easier debugging. - A
RejectedExecutionHandler(e.g.,CallerRunsPolicy) is crucial for gracefully managing system overload. - You can apply a custom executor by name (
@Async("myExecutor")) or set a global default by implementingAsyncConfigurer.
What's Next?
So far, we've used @Async for direct, fire-and-forget method calls. This is a form of tightly coupled asynchronous communication. In our next lesson, we'll explore a more decoupled approach: application events. You'll learn how to define, publish, and listen for events within your Spring application, enabling different parts of your system to react to things that happen without being directly aware of each other. This is a foundational pattern for building modular and scalable applications.