What is the default thread model for @Scheduled, why is it a common production pitfall, and how do you size a ThreadPoolTaskScheduler correctly?
answer
- default TaskScheduler = 1 thread
- one slow task starves all others
- ThreadPoolTaskScheduler + setPoolSize
- poolSize = peak concurrent jobs
- scheduler triggers, @Async does heavy work
basics
~20 sBy default Spring uses a single-threaded scheduler, so all @Scheduled methods share one thread. A slow or blocked task delays every other scheduled task. Fix it by defining a ThreadPoolTaskScheduler bean with a pool size big enough for your concurrent jobs.
solid answer
~50 sIf you don't provide a TaskScheduler bean, Spring's scheduling uses a single-threaded executor. All @Scheduled methods across the whole app queue on that one thread, so if one task runs long or blocks (a slow HTTP call, a lock), every other scheduled task is delayed or starved, and fixedRate tasks bunch up. The fix is to declare a ThreadPoolTaskScheduler (or set spring.task.scheduling.pool.size in Boot) with poolSize sized to the number of tasks that may run simultaneously — roughly the count of overlapping jobs, not a huge number, since scheduler threads should hand off heavy work rather than do it all. Give it a thread-name prefix for observability and configure graceful shutdown (setWaitForTasksToCompleteOnShutdown, setAwaitTerminationSeconds). Note the pool caps concurrency across all scheduled tasks; a single misbehaving task can still consume a thread, so isolate risky jobs or offload to @Async executors.
code
java · 17 lines@Configuration
@EnableScheduling
public class SchedulingConfig {
@Bean
public ThreadPoolTaskScheduler taskScheduler() {
var scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(4); // max concurrent scheduled tasks
scheduler.setThreadNamePrefix("sched-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
return scheduler;
}
}
// Boot alternative (no bean needed):
// spring.task.scheduling.pool.size=4
// spring.task.scheduling.thread-name-prefix=sched-go deeper
Knows the default is single-threaded and that a pool exists.
Can create a ThreadPoolTaskScheduler bean or set the Boot property.
Reasons about starvation, sizing to peak concurrency, and graceful shutdown.
Separates scheduler vs async pools, offloads heavy work, and addresses cluster-wide dedup and observability.
**The default: one thread.** Spring's `@EnableScheduling` needs a `TaskScheduler`. If you don't supply one, it falls back to creating a **single-threaded** `ScheduledExecutorService` (wrapped so one thread services *all* `@Scheduled` methods in the application). Boot's `TaskSchedulingAutoConfiguration` similarly provides a `ThreadPoolTaskScheduler` whose default pool size is **1**. **Why this is a pitfall.** Because there is one thread, scheduled tasks are effectively **serialized**. Consequences: - A task that **blocks** (slow downstream call, DB lock, `Thread.sleep`) holds the only thread; every *other* scheduled job across the app is delayed until it returns. - `fixedRate` jobs cannot keep cadence — overdue ticks queue and fire back-to-back (see the fixedRate question). - One buggy job can **starve** critical ones (e.g. a health-flush job never runs because a report job hangs). This often shows up only in production under load and is hard to reproduce. **The fix: a multi-threaded `ThreadPoolTaskScheduler`.** Define it as a `@Bean` (Spring auto-detects a `TaskScheduler` bean and uses it). Key settings: - `setPoolSize(n)` — the number of scheduler threads. This is the maximum number of scheduled tasks that can execute **concurrently**. - `setThreadNamePrefix("sched-")` — makes thread dumps and logs readable. - `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(...)` — graceful shutdown so in-flight jobs finish instead of being killed. - optionally `setErrorHandler(...)` and `setRejectedExecutionHandler(...)`. In Boot you can skip the bean and set properties: `spring.task.scheduling.pool.size=4`, `spring.task.scheduling.thread-name-prefix=sched-`, `spring.task.scheduling.shutdown.await-termination=true`. **Sizing guidance.** The pool size should cover the **peak number of scheduled tasks that may need to run at the same instant**, plus a little headroom — not an arbitrarily large number. Reasoning: scheduler threads are a shared, fixed resource; if you make the pool huge you mask a design smell (heavy work running *on* the scheduler thread). Better pattern: keep scheduler threads light — they should *trigger* work and hand off CPU/IO-heavy work to a dedicated `@Async` `ThreadPoolTaskExecutor` (a separate pool). A common heuristic: `poolSize` = number of distinct jobs that can overlap, e.g. if you have 5 jobs but at most 2 ever run concurrently given their cron/rates, a pool of 2–3 is plenty. Note `ThreadPoolTaskScheduler` uses a `ScheduledThreadPoolExecutor` internally, which has a **fixed core pool and no queue-bounded growth** — it does not grow beyond `poolSize`, so undersizing serializes and oversizing wastes threads. **Scheduler vs executor (don't confuse them).** `TaskScheduler`/`ThreadPoolTaskScheduler` backs `@Scheduled`. `TaskExecutor`/`ThreadPoolTaskExecutor` backs `@Async`. They are separate pools with separate config (`spring.task.execution.*` vs `spring.task.scheduling.*`). Sizing @Async is a different question. **Residual caveats even with a pool.** The pool caps *total* concurrency, so a burst of tasks beyond `poolSize` still queues. And a task can still hog a thread; isolate genuinely risky/long jobs onto their own scheduler or push them to @Async. For cluster-wide single execution you still need a distributed lock (ShedLock) — pool sizing is per-instance only.
- You define a ThreadPoolTaskScheduler with poolSize=1. Is that any different from the default?Functionally the same concurrency (one thread, tasks serialized), but you gain control over thread naming, shutdown behavior, and error handling. To actually run scheduled tasks in parallel you need poolSize > 1.
- Should you make poolSize very large to be safe?No. Oversizing wastes threads and hides a design smell — heavy work running on the scheduler thread. Size it to peak concurrent scheduled jobs and offload CPU/IO-heavy work to a separate @Async ThreadPoolTaskExecutor so scheduler threads stay light.
- Does a bigger pool help when the same job runs on 5 app instances?No. Pool size is per-instance. Every instance still runs the job independently. For run-once-cluster-wide you need a distributed lock like ShedLock or leader election.
saying these in an interview costs you the question
- Assuming @Scheduled is multi-threaded by default
- Setting poolSize to a huge number as a blanket fix
- Confusing the scheduler pool (@Scheduled) with the executor pool (@Async)
- Thinking a bigger pool prevents duplicate runs across multiple instances
- Doing heavy blocking work directly on the scheduler thread