prev: [Return types and exceptions](04-return-types-and-exceptions.md) · [README](../README.md) · next: [Context propagation](06-context-propagation.md) # 5. Pool sizing, and why `max-size` usually does nothing The stock executor, printed from the live bean in [`docs/output/executor-report.txt`](output/executor-report.txt): ``` applicationTaskExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor corePoolSize=8 maxPoolSize=2147483647 queueCapacity=2147483647 threadNamePrefix=task- ``` Eight core threads, an effectively unbounded maximum, and an effectively unbounded queue. `ThreadPoolTaskExecutor` delegates to `java.util.concurrent.ThreadPoolExecutor`, whose growth rule is the part people misremember. It is not "grow under load". It is: 1. fewer than `corePoolSize` threads → create a thread; 2. otherwise → offer the task to the queue; 3. **only if the queue refuses** → create a thread, up to `maxPoolSize`; 4. otherwise → reject. An unbounded queue never refuses. So with the defaults, step 3 is unreachable and `maxPoolSize` is decoration. Two runs of the same 16 blocking tasks, differing in one property: | properties | distinct threads used | |---|---| | `core-size=4`, `max-size=12` | **4** ([output](output/pool-unbounded-queue.txt)) | | `core-size=4`, `max-size=12`, `queue-capacity=4` | **12** ([output](output/pool-bounded-queue.txt)) | Triple the concurrency, one line of YAML. The corollary is that raising `max-size` alone — the usual response to a slow async pipeline — changes nothing at all. ## What to set instead - **Set `queue-capacity` deliberately.** It is the backpressure boundary. Unbounded means an incident consists of a heap filling up rather than tasks being rejected, which is the worse of the two failure modes because it takes the whole process with it. - **Size `core-size` to the work, not to the CPU.** Async work in most Spring applications is I/O bound, so the useful number is closer to "how many concurrent downstream calls will that service tolerate" than to `Runtime.availableProcessors()`. - **Decide what rejection means.** The default policy is `AbortPolicy`, so a full queue and a full pool produce `RejectedExecutionException` at the *call site* — synchronously, in whichever thread called the `@Async` method. If you want the caller to absorb the load instead, use `CallerRunsPolicy`, and understand that you have just made the method synchronous under saturation. ## Shutdown `spring.task.execution.shutdown.await-termination` is `false` by default. On shutdown, tasks that are still queued are simply dropped. If your async work is "send the email" rather than "warm the cache", set it to `true` and give `spring.task.execution.shutdown.await-termination-period` a bound. next: [Context propagation](06-context-propagation.md)