- Published on
- · Updated
Spring Boot Thread Pool Configuration: @Async Defaults, Queue Capacity, and Tuning
- Authors

- Name
- Maria
Spring Boot thread pool configuration is easy to get almost right. A pool may have a sensible core-size, an impressive max-size, and still run only the core threads because its queue never fills. Another application may enable virtual threads and silently ignore every pool property in the configuration file.
The short answer for Spring Boot 4.1 is:
- without a custom
Executor, Spring Boot auto-configures anAsyncTaskExecutor; - with virtual threads disabled, that executor is a
ThreadPoolTaskExecutorwith 8 core threads; - its queue is unbounded unless
spring.task.execution.pool.queue-capacityis set, somax-sizedoes not affect scaling by default; - with
spring.threads.virtual.enabled=true, Boot uses aSimpleAsyncTaskExecutorbacked by virtual threads and ignores the pool sizing properties.
That answer applies to Boot's task executor, not every pool in the application. The embedded web server, database connection pool, scheduler, Kafka consumers, and Reactor all have separate concurrency controls.
Version note
This guide was verified against Spring Boot 4.1.0 and Spring Framework 7.0.8 on August 10, 2026. Older Boot lines can differ, so check the reference documentation for the version deployed in production.
TL;DR
- Set
queue-capacityas well asmax-size; an unbounded queue prevents the pool from growing beyondcore-size.- Treat pool sizes as capacity limits, not magic performance numbers.
- Size blocking work against downstream limits such as database connections, HTTP connection pools, and API quotas.
- Keep rejection visible.
CallerRunsPolicycan create backpressure, but it can also block a Tomcat thread or message-consumer thread.- Use named executors for workloads with different latency and failure characteristics.
- Virtual threads reduce thread cost; they do not increase database, network, or remote-service capacity.
Know which thread pool you are configuring
Several unrelated settings are commonly described as “the Spring Boot thread pool.”
| Work | Typical configuration | What it controls |
|---|---|---|
@Async and Boot task execution | spring.task.execution.* | Application background tasks and several Boot integrations |
@Scheduled methods | spring.task.scheduling.* | Scheduled task execution |
| Tomcat request handling | server.tomcat.threads.* | Servlet request worker threads |
| Database access | spring.datasource.hikari.* | JDBC connections, not Java worker threads |
| Reactor / WebFlux operators | Reactor scheduler APIs | Reactive execution boundaries |
Changing spring.task.execution.pool.max-size does not change Tomcat's request-thread limit. Increasing Tomcat threads does not create more database connections. Tune the component that is actually saturated.
Spring Boot's auto-configured task executor is used by @EnableAsync unless an AsyncConfigurer selects another executor. It can also support Spring MVC asynchronous requests, WebFlux blocking execution, WebSocket channels, GraphQL, JPA bootstrap, and background bean initialization. A custom Executor bean normally makes that auto-configuration back off, so bean names and types matter when an application defines more than one executor.
Spring Boot 4.1 default task executor
With no custom Executor bean, the default depends on the virtual-thread switch.
| Setting | Auto-configured executor | Important defaults |
|---|---|---|
spring.threads.virtual.enabled=false | ThreadPoolTaskExecutor | core size 8, core timeout enabled, 60-second keep-alive, unbounded queue |
spring.threads.virtual.enabled=true | SimpleAsyncTaskExecutor using virtual threads | pool properties ignored; concurrent task count is unlimited unless separately constrained |
There is an important source of contradictory advice here. A manually constructed Spring Framework ThreadPoolTaskExecutor has a core size of 1, an unlimited maximum size, and an unlimited queue by default. Spring Boot's auto-configuration applies different task-execution defaults, including a core size of 8.
So “the default pool size is 1” and “the default pool size is 8” can both describe real objects:
- 1 is the raw
ThreadPoolTaskExecutorclass default; - 8 is the Spring Boot 4.1 auto-configured task executor's core size;
- 1 is also the default non-virtual
ThreadPoolTaskSchedulersize for scheduled work.
Always identify the bean and Boot version before comparing numbers.
Why max-size is often ignored
ThreadPoolExecutor processes a submitted task in this order:
- If fewer than
core-sizethreads are running, start another thread. - Once the core threads are busy, put the task in the work queue.
- Only when the queue cannot accept the task, grow the pool up to
max-size. - When both the maximum pool and queue are full, apply the rejection policy.
Consider this configuration:
spring:
task:
execution:
pool:
core-size: 8
max-size: 32
queue-capacity: 100
Assuming no task finishes while the burst arrives:
- tasks 1 through 8 occupy the core threads;
- the next 100 tasks wait in the queue;
- the next 24 tasks cause the pool to grow from 8 to 32 threads;
- another task is rejected because 32 threads are active and 100 tasks are queued.
Remove queue-capacity, and the unbounded queue continues accepting tasks. The pool stays at 8 active threads, even though max-size says 32. That behavior is defined by Java's ThreadPoolExecutor, not a Spring-specific bug.
An unbounded queue can absorb a short burst, but it also lets waiting work accumulate without a capacity signal. If submissions remain faster than completions, queue latency and memory use continue to grow. A bounded queue makes overload visible and gives max-size a chance to participate.
Configure the auto-configured pool with properties
For one general-purpose task executor, application properties are usually enough.
spring:
task:
execution:
thread-name-prefix: app-async-
pool:
core-size: 8
max-size: 32
queue-capacity: 100
keep-alive: 30s
allow-core-thread-timeout: true
shutdown:
await-termination: true
await-termination-period: 30s
This is an example shape, not a universal production preset. In particular, queue-capacity: 100 is useful only when the application can tolerate the memory use and queueing delay created by 100 waiting tasks.
The shutdown options tell Boot to wait for accepted work during an orderly application shutdown. They do not make an in-memory task durable. A process crash, forced container termination, or exhausted shutdown grace period can still lose queued work.
If the application defines another Executor bean but should retain Boot's auto-configured executor for framework integrations, review spring.task.execution.mode=force and the applicationTaskExecutor naming rules in the Boot reference. Do not enable force mode merely to hide ambiguous executor wiring; verify which bean each integration actually uses.
Use named executors to isolate workloads
One shared pool couples unrelated work. A slow notification provider can fill the same queue used for audit processing, file conversion, or cache refreshes. Named executors give each workload its own capacity, rejection behavior, and metrics.
package com.example.async;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
@Configuration(proxyBeanMethods = false)
@EnableAsync
class AsyncConfiguration {
@Bean("notificationExecutor")
ThreadPoolTaskExecutor notificationExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(24);
executor.setQueueCapacity(100);
executor.setKeepAliveSeconds(30);
executor.setAllowCoreThreadTimeOut(true);
executor.setThreadNamePrefix("notification-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
return executor;
}
}
@Service
class NotificationService {
@Async("notificationExecutor")
public CompletableFuture<Void> sendReceipt(String orderId) {
// Call a client with explicit connect, read, and total timeouts.
return CompletableFuture.completedFuture(null);
}
}
Because the executor is a Spring-managed bean, the container initializes and shuts it down. Calling initialize() manually inside the @Bean method is unnecessary.
The explicit AbortPolicy keeps overload visible. Spring adapts task rejection to its TaskExecutor contract, so callers should be prepared for TaskRejectedException at submission time. Translate that failure at the boundary where the application can make a business decision: reject the request, retry later, persist a durable job, or return a clear overload response.
Choose a rejection policy deliberately
A bounded executor must define what happens at saturation.
| Policy | Behavior | Suitable only when |
|---|---|---|
AbortPolicy | Throws on submission | The caller can handle overload explicitly |
CallerRunsPolicy | Runs the task on the submitting thread | Blocking that caller is understood and acceptable |
DiscardPolicy | Drops the new task | The task is intentionally lossy and drops are measured |
DiscardOldestPolicy | Drops the oldest queued task and retries | Reordering and loss are explicitly acceptable |
CallerRunsPolicy is often described as automatic backpressure. That is only half the story. If a Tomcat request thread submits the task, the request now performs the background work and its latency increases. If a Kafka listener submits it, the consumer thread stops polling while it runs the task. If submission occurs inside a transaction, the work may execute on that transaction's thread rather than the expected background thread.
That feedback can be useful, but it changes execution semantics. Use it after testing the actual caller, not as a copy-pasted safety switch.
Discard policies are even narrower. If an email, payment instruction, state transition, or audit record must eventually happen, an in-memory executor with silent discard is the wrong reliability boundary. Persist the intent in a durable queue or outbox and make delivery retryable.
Size the pool from measured capacity
There is no exact formula that turns CPU count into the correct core-size, max-size, and queue capacity for every service. Use a calculation to form a starting hypothesis, then test it under representative load.
CPU-bound tasks
For compression, encryption, parsing, or other CPU-heavy work, start near the CPU capacity available to the process. More runnable platform threads than available processors can increase context switching without increasing throughput. Container CPU limits matter more than the host machine's total core count.
Use a small queue so the system exposes overload before a long backlog forms. Measure throughput and tail latency while increasing concurrency; stop when additional workers no longer improve useful work.
Blocking I/O tasks
Blocking HTTP or database work can use more worker threads because each thread spends time waiting. The first limit, however, is usually downstream:
- JDBC connection pool size;
- HTTP client's maximum connections per destination;
- remote API rate limit;
- broker or consumer concurrency;
- memory retained by each in-flight request.
If a database pool has 20 connections, configuring 100 database worker threads does not create 100-way database throughput. It can create 80 threads waiting for connections while holding request data and consuming the executor's latency budget.
A practical concurrency estimate is:
For 100 tasks per second with an average duration of 0.2 seconds, the starting estimate is 20 concurrent tasks. Add measured headroom for variance, then validate p95 and p99 latency, CPU, memory, downstream waits, and rejection rate. An average alone does not capture slow dependencies or bursty arrivals.
Queue capacity is a latency decision
A queue is not free capacity. It stores latency.
If the executor sustainably completes 100 tasks per second, a full queue of 500 tasks takes roughly five seconds to drain before considering new arrivals. If the business latency budget is one second, that queue is already too large even when memory is plentiful.
Choose queue capacity from:
- the maximum queueing delay the caller can tolerate;
- the sustainable completion rate, not a short benchmark peak;
- the memory retained by each queued task;
- the desired overload response.
Then run a test where arrival rate remains above service rate. A pool is not safely configured until its saturated behavior is known.
Monitor the signals that explain saturation
ThreadPoolTaskExecutor exposes the current pool size, active count, queue size, core size, and maximum size. Export those values through the application's metrics system and correlate them with request and downstream metrics.
Watch at least:
- active threads and current pool size;
- queue depth and queue wait time;
- completed and rejected tasks;
- task execution p50, p95, and p99;
- JVM CPU, allocation rate, heap, and garbage collection;
- database connection wait time;
- HTTP client pending connections and remote latency;
- caller latency when
CallerRunsPolicyis used.
The patterns are more useful than any one number:
- queue rising while active threads equal
core-sizecan mean an unbounded queue is preventing growth; - active threads at
max-sizewith a full queue means sustained saturation; - low executor utilization with high latency points to a downstream wait or a different pool;
- growing queue depth with stable traffic means service rate has fallen below arrival rate.
Virtual threads change the executor, not the dependency limits
On Java 21 or later, Spring Boot can use virtual threads for task execution:
spring:
threads:
virtual:
enabled: true
task:
execution:
simple:
concurrency-limit: 100
With the virtual-thread switch enabled, Boot uses SimpleAsyncTaskExecutor with virtual threads. Settings under spring.task.execution.pool.* do not apply. The separate simple.concurrency-limit property limits parallel task execution; without a limit, concurrency is unrestricted by default.
Virtual threads make a large number of blocking tasks cheaper than one platform thread per task, but they do not make a 20-connection database pool serve 1,000 simultaneous queries. Keep timeouts and downstream concurrency limits, and load-test the complete dependency path. On Java 24 and later, JEP 491 removed the common synchronized pinning limitation, but native calls, lock contention, and library behavior can still constrain scalability.
If virtual threads are disabled, avoid treating a platform-thread SimpleAsyncTaskExecutor as a high-volume pool. The Spring Framework documentation is explicit that it does not reuse threads.
@Async configuration mistakes that look like pool problems
Some failures happen before pool tuning matters.
Self-invocation stays synchronous
The default @Async advice uses a Spring proxy. A method calling another @Async method on the same instance bypasses that proxy, so the call runs synchronously.
Move the asynchronous method to another Spring bean or invoke it through an intentional proxy boundary. Confirm the thread name in a test instead of assuming the annotation was intercepted.
@Async moves blocking work; it does not make it non-blocking
An @Async method that performs a blocking HTTP call still occupies a worker for the duration of that call. Configure connection, read, and total deadlines on the client. Otherwise, a slow dependency can occupy every worker indefinitely.
void hides failure from the caller
Exceptions from Future or CompletableFuture results can be observed by the caller. Exceptions from a void @Async method cannot be returned and are only logged by default unless an AsyncUncaughtExceptionHandler is configured.
Use an observable return type when the result matters, and record terminal failures independently of application logs.
Thread-local and transaction context does not move automatically
The task executes on another thread, so transaction state, logging MDC, security context, and other thread-local data require an explicit propagation strategy. Pass stable identifiers in the task payload and start a new transaction where background database work requires one.
In-memory async work is not durable
@Async is useful when work may be lost with the process or can be reconstructed. It is not a replacement for a message broker, job table, or transactional outbox when the operation must survive a restart.
Do not confuse @Async with @Scheduled
Without virtual threads, Spring Boot's task scheduler uses one thread by default. A long-running scheduled method can delay every other scheduled method even when the @Async executor is correctly tuned.
Configure scheduler concurrency separately:
spring:
task:
scheduling:
thread-name-prefix: scheduler-
pool:
size: 4
If a scheduled method only discovers work and hands durable jobs to another system, a small scheduler can be enough. If scheduled methods perform work directly, test overlap, execution time, and shutdown behavior.
Verification checklist
Before shipping a thread pool change, verify:
- the exact executor bean used by
@Asyncis known; -
core-size,max-size, andqueue-capacityare all visible at runtime; -
max-sizeis not neutralized by an unbounded queue; - task and client timeouts are shorter than the business deadline;
- downstream connection and rate limits are included in the sizing decision;
- rejection is counted, logged, and handled at a defined boundary;
- shutdown behavior is tested with queued and running tasks;
- self-invocation tests prove that
@Asyncis actually asynchronous; - failures from asynchronous methods are observable;
- sustained overload produces a deliberate response rather than unlimited backlog;
- work that must survive a crash uses durable storage.
Frequently asked questions
What is the default Spring Boot thread pool size?
For Spring Boot 4.1's auto-configured, non-virtual task executor, the core size is 8. A manually constructed ThreadPoolTaskExecutor defaults to a core size of 1, and the non-virtual task scheduler also defaults to 1. The correct answer depends on the bean and framework version.
Why does spring.task.execution.pool.max-size do nothing?
The default queue is unbounded. ThreadPoolExecutor queues work after the core threads are busy and creates threads beyond the core only when the queue is full. Set a finite queue-capacity if the pool should grow toward max-size.
Do I need a custom ThreadPoolTaskExecutor bean?
Not for a single default pool. spring.task.execution.* can configure its size, queue, keep-alive, thread names, and shutdown behavior. Use a custom bean when workloads need separate pools, a custom rejection handler, task decoration, or explicit executor selection.
Does a bounded queue prevent every out-of-memory failure?
No. It bounds one source of retained tasks. Running tasks, request bodies, downstream client queues, caches, retries, and other application state can still exhaust memory. A bounded queue must be combined with limits, timeouts, rejection handling, and load testing.
Should every blocking application enable virtual threads?
No. Virtual threads can simplify high-concurrency blocking code, but compatibility and downstream limits still decide whether they help. Benchmark the complete request path and set a concurrency limit when the dependency cannot safely absorb unrestricted parallelism.
Related reading
- Testing Spring Boot Kafka Microservices with Testcontainers
- Spring Boot 4 Observability with OpenTelemetry, Prometheus, and Grafana
Official sources
- Spring Boot: Task Execution and Scheduling
- Spring Boot: Common Application Properties
- Spring Framework: Task Execution and Scheduling
- Spring Framework: ThreadPoolTaskExecutor API
- Spring Framework: SimpleAsyncTaskExecutor API
- Java SE 25: ThreadPoolExecutor
The safest configuration is not the one with the largest pool. It is the one that makes capacity, queueing delay, rejection, dependency limits, and failure recovery explicit. Start with measured demand, keep the queue finite, and test what happens after the pool is full.