Spring @Scheduled — Cron Expressions, Fixed Rate, Thread Pool, and What Breaks in Production
Master Spring @Scheduled: cron expressions, fixedRate vs fixedDelay, thread pool config, zones, and the production pitfalls that cause tasks to silently stop running..
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- @Scheduled runs on a single-threaded pool by default — tasks block each other if they take too long
- fixedRate fires every N ms from the last start time; fixedDelay fires N ms after the last completion
- Cron expressions in Spring follow: second minute hour day-of-month month day-of-week
- Enable async scheduling with @EnableAsync + @Async on scheduled methods, or configure a TaskScheduler bean
- @Scheduled doesn't work on beans not managed by Spring — instantiating with 'new' silently skips scheduling
Spring @Scheduled is like setting a kitchen timer. You can set it to ring every 10 minutes (fixedRate), wait 10 minutes after you finish cooking before ringing again (fixedDelay), or ring precisely at 9 AM every Monday (cron). The catch is all timers share one cook — if one task takes 30 minutes, all others wait.
Spring's @Scheduled annotation is deceptively simple. You put it on a method, set an interval, done. And then six months later your operations team notices that the nightly data reconciliation job that's supposed to run at 2 AM has been silently failing to run for three weeks because a database lock caused one invocation to run for 45 minutes, blocking the single scheduler thread, causing subsequent invocations to queue up and eventually get dropped.
The single-threaded-by-default behavior of Spring's scheduler is the root cause of most scheduling production incidents. It's not documented prominently enough. Developers assume scheduled tasks run concurrently, like cron on a Linux system. They don't — unless you explicitly configure a thread pool. A single slow task stops everything.
Beyond thread pools, there are other subtle issues: @Scheduled annotations on non-Spring-managed beans don't schedule anything and give no error; cron expressions in Spring have a seconds field that standard Unix cron doesn't; fixedRate and fixedDelay semantics are confused regularly; zone configuration is needed for cron jobs to respect daylight saving time; and in clustered environments, every node runs every scheduled task independently — causing duplicate work.
This article covers all of these with production examples. We'll configure proper thread pools, explain cron syntax with real examples, show how to handle clustered scheduling with ShedLock, and debug the most common scheduling failures.
Configuring the Thread Pool — The Most Important @Scheduled Setting
Spring's default scheduler uses a single thread. That's documented, but easy to miss. Every @Scheduled method in your application — all of them — run on that one thread, sequentially. If you have a fixedRate task that should fire every 1 second, but the previous invocation is still running at the 1-second mark, the new invocation waits. It doesn't fire concurrently; it queues.
The fix is to define a ThreadPoolTaskScheduler bean. Spring auto-configures the scheduler, but it respects your bean if you define one named 'taskScheduler'. Set the pool size based on the number of concurrent scheduled tasks you expect plus headroom for occasional long-running tasks. A pool of 5-10 is appropriate for most applications with up to 20 scheduled tasks where most tasks complete quickly.
Alternatively, if you want scheduled tasks to be truly independent and concurrent, combine @Scheduled with @Async. The @Scheduled method runs on the scheduler thread just long enough to submit work to the async executor, then returns. The actual work runs on a separate @Async thread pool. This approach keeps scheduler threads available for timing coordination while offloading actual processing to a properly sized async pool.
For tasks that should never overlap — a task that shouldn't start if the previous invocation is still running — use fixedDelay instead of fixedRate. fixedDelay waits N milliseconds after completion before the next invocation, guaranteeing no overlap. If you need fixedRate semantics (fire at regular intervals) but also need no overlap, add a flag: if still running, skip this invocation.
Monitor your scheduler thread pool with Micrometer metrics. The metrics executor.pool.size, executor.active, and executor.queue.size (with tag name:taskScheduler) show pool utilization in real time. Set alerts on executor.active approaching executor.pool.size — that indicates saturation and impending task delays.
Cron Expressions in Spring — The Hidden Seconds Field
Spring's cron expressions have 6 fields, not 5 like traditional Unix/Linux cron. The first field is seconds, which Unix cron doesn't have. This trips up every developer who's familiar with Linux cron syntax. The Spring cron format is: second minute hour day-of-month month day-of-week.
So `0 30 9 * * MON-FRI` means: second 0, minute 30, hour 9, every day-of-month, every month, Monday through Friday = 9:30:00 AM on weekdays. If you write 30 9 MON-FRI (5 fields), Spring throws an IllegalArgumentException at startup — which is good (fail fast). If you write 30 9 thinking it's 'every 9th minute past 30', you'll be confused when it runs at 09:30:00 daily.
Some useful Spring cron examples: 0 /15 = every 15 minutes (at :00, :15, :30, :45). 0 0 0 = midnight every day. 0 0 12 1 = noon on the 1st of every month. 0 0 8-18 MON-FRI = every hour from 8 AM to 6 PM on weekdays. 0 0 9 MON = 9 AM every Monday.
Spring 5.3+ supports the special value @yearly, @monthly, @weekly, @daily, @hourly as macro expressions — but they map to 5-field Unix cron internally, which Spring then pads with '0' for seconds. Don't rely on these macros for production code; use explicit 6-field expressions.
Timezone handling is critical for cron jobs. Without specifying zone, Spring uses the JVM's default timezone (usually UTC in Docker containers). If your business requirement is '9 AM New York time', you must specify zone = 'America/New_York' — otherwise the job fires at 9 AM UTC (4 AM or 5 AM New York depending on DST). Spring correctly handles DST transitions when you specify the zone.
fixedRate vs fixedDelay — Choosing the Right Semantics
fixedRate and fixedDelay are often confused, and the choice between them has significant implications for task behavior under load. Understanding the exact semantics prevents overlap issues and task queue buildup.
fixedRate schedules invocations at a fixed interval measured from the start time of the previous invocation. If the task takes 3 seconds and the rate is 5 seconds, the next invocation starts 2 seconds after the previous one finishes. If the task takes 7 seconds (longer than the rate), the next invocation starts immediately after the previous one finishes — there's no queuing with the single-thread default, but with a thread pool, a second thread could start the new invocation before the first finishes, causing overlap.
fixedDelay schedules the next invocation N milliseconds after the previous invocation completes. If the task takes 3 seconds and the delay is 5 seconds, the next invocation starts 5 seconds after the 3-second task finishes = 8 seconds after the last start. fixedDelay never causes overlapping invocations — by definition, each new invocation starts after the previous one completes plus the delay.
Practical guidance: Use fixedDelay when: (a) tasks must not overlap, (b) the task modifies shared state and concurrent execution would cause corruption, (c) you're polling an external system and concurrent polls don't make sense (e.g., reading from a queue). Use fixedRate when: (a) you want consistent metric sampling intervals, (b) tasks are read-only and stateless, (c) occasional overlap is acceptable and you have a thread pool to support it.
Both fixedRate and fixedDelay accept initialDelay to postpone the first execution. This is useful for tasks that need to wait for the application to be fully ready — though ApplicationReadyEvent is often a cleaner solution for first-run delay.