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.
- @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.
Distributed Scheduling with ShedLock — Preventing Duplicate Execution
In a multi-instance deployment (which is every production Kubernetes deployment with replicas > 1), Spring's @Scheduled runs on every pod simultaneously. For stateless tasks like sending metrics to a monitoring system, this is fine — even desirable. For stateful tasks like sending a daily summary email, running a database reconciliation, or processing a batch job, this causes duplicate work at best and data corruption at worst.
ShedLock is the de-facto solution for distributed Spring scheduling. It uses a shared store (database table, Redis, ZooKeeper, MongoDB) to coordinate which node runs a scheduled task. When a task fires, ShedLock tries to acquire a lock in the shared store with the task name, host, and expiry time. If the lock is acquired, the task runs. If not (another node holds the lock), the invocation is skipped. The lock expires after lockAtMostFor time, guaranteeing eventual lock release even if the holding node crashes.
ShedLock configuration requires adding the dependency, creating a lock table in your database (or configuring Redis), and annotating methods with @SchedulerLock. The lockAtLeastFor parameter ensures the lock is held for a minimum time — preventing situations where a fast task finishes in 100ms and another node immediately acquires the lock and re-runs (useful for tasks that should truly run once per interval across the cluster).
The lock expiry (lockAtMostFor) must be set carefully. It should be longer than the maximum expected task runtime, but not so long that a crashed node blocks the task for an unreasonable time. For a task that normally takes 30 seconds with occasional outliers up to 5 minutes, set lockAtMostFor to 10 minutes — longer than the worst case, short enough to recover quickly if the node crashes.
Alternatives to ShedLock: Spring Batch for complex stateful batch jobs (it has built-in cluster coordination). Quartz Scheduler with JDBC JobStore for complex scheduling requirements (triggers, misfires, clustering). For simple use cases, Quartz adds significant complexity and ShedLock is the right tool.
Dynamic Scheduling — Changing Intervals at Runtime
Sometimes you need to change a schedule at runtime without restarting the application. Maybe your operations team wants to pause a background job during peak hours. Maybe a schedule configuration comes from a database. @Scheduled annotations are static — the interval is fixed at startup. For dynamic scheduling, you need to use the SchedulingConfigurer interface or TaskScheduler directly.
SchedulingConfigurer lets you programmatically register tasks during context initialization. You implement the interface, override configureTasks(ScheduledTaskRegistrar), and add tasks to the registrar. You can add Runnable tasks with a Trigger (cron or periodic trigger) that reads its interval from a configuration source at each invocation — meaning the next trigger time is recalculated using the current configuration value.
For runtime scheduling changes, the TaskScheduler bean provides schedule() methods that return ScheduledFuture handles. You can cancel a scheduled task by calling future.cancel(false) and reschedule it with a new interval. Wrap this in a management service that your operations team can call via an actuator-style endpoint or an admin UI.
For enterprise dynamic scheduling, Quartz provides a full-featured solution: persistent job store, cluster coordination, calendar-based exclusions, job chaining, and programmatic schedule management via a Scheduler API. But Quartz adds complexity — for simple dynamic intervals, SchedulingConfigurer is sufficient.
The pattern for configurable-from-properties scheduling (without full dynamic runtime changes) is to use @Scheduled with property expressions: @Scheduled(fixedRateString = '${app.job.rate-ms}'). This makes the interval configurable via application.properties or environment variables without code changes — sufficient for most cases where you just need different intervals per environment.
TaskScheduler.scheduleAtFixedRate() returns a cancellable ScheduledFuture.Monitoring and Observability for Scheduled Tasks
Scheduled tasks are invisible by default. They run in the background, and unless you're actively watching logs, you don't know if they're running, how long they take, or when they last succeeded. This invisibility is dangerous in production — tasks that silently fail can go unnoticed for days.
Spring Boot Actuator's /actuator/scheduledtasks endpoint lists all registered scheduled tasks with their expressions and last execution times. Enable it by adding 'scheduledtasks' to management.endpoints.web.exposure.include. This is your first line of defense — you can verify that tasks are registered and see their next scheduled time.
For deeper monitoring, instrument scheduled methods with Micrometer. Record task execution time, success/failure counts, and last successful run timestamp. Expose these as custom metrics and set up alerts: if 'last successful run' is more than 2x the schedule interval ago, something is wrong.
A simple but effective pattern is to update a health indicator from your most important scheduled tasks. If the nightly reconciliation job hasn't run in 25 hours, your health indicator shows degraded. Kubernetes liveness probes that check this endpoint will restart a pod that's stuck with all scheduled tasks blocked — automatic recovery.
For distributed scheduled tasks using ShedLock, add monitoring for lock acquisition failures and lock expiry. If locks are expiring before tasks complete (lockAtMostFor too short), or if locks are never being acquired (no node is winning the lock), these are signals of problems that need investigation.
Enable Scheduling Without Regret — Why @EnableScheduling Is Non-Negotiable
You spent six hours debugging a silent failure. The @Scheduled method was perfect. The thread pool was tuned. But nothing ran. You forgot @EnableScheduling.
This annotation turns on Spring's task scheduling infrastructure. Without it, @Scheduled is just a dead annotation on a method that never executes. The mistake is embarrassingly common — I've seen it in production three times this year alone.
Drop @EnableScheduling on any @Configuration class. One line. One brain cell. It registers a TaskScheduler bean and wires the annotation post-processor. If you're using Spring Boot's auto-configuration, it will even pick up spring.task.scheduling properties from your application.yml.
The XML equivalent exists, but stop using XML configs in 2023. You're not writing Struts 1 anymore.
Pro tip: put @EnableScheduling on your main application class. If you forget, your cron expression is just a comment with extra symbols.
Parameterizing Your Schedule — Environment Variables Beat Hardcoded Magic Numbers
That 5000 millisecond fixedDelay you hardcoded? It will fail. Not today. But next month when you deploy to a new environment where 3000ms is correct. Or when your ops team asks 'how do I change the interval without a rebuild?' and you mumble something about recompilation.
Stop hardcoding timing values. Use Spring's property placeholders with @Scheduled.
The syntax is simple: ${property.name:defaultValue}. Spring resolves these from any PropertySource — application.yml, environment variables, command-line arguments, or custom sources. This gives you environment-specific scheduling without touching code.
Cron expressions are also parameterizable. Put your cron string in a property and let different environments define different schedules. Production runs every hour? Staging every 5 minutes during testing? One property change, zero rebuilds.
Remember your production lesson: configurable schedules mean less pager alerts and faster incident response. The five minutes you spend refactoring magic numbers to properties saves your team a full day when the business changes requirements.
Running Tasks in Parallel Without Breaking Your Thread Pool
Default @Scheduled behavior: one thread. One task at a time. Your fixedRate of 1000ms with a task that takes 2000ms? You get a backlog. The next execution waits until the current one finishes, regardless of your fixedRate setting. This is the silent performance killer that turns your scheduler into a serial executor.
Fix it with @Async. Add @EnableAsync to your configuration and @Async to your scheduled method. Spring will run each invocation in a separate thread from the task executor.
But here's the trap: default thread pool has unlimited growth. One slow task loops? You get thread explosion and an OutOfMemoryError in production — I've debugged that mess at 2 AM.
Configure the async executor with bounded queue and fixed thread pool size. Spring Boot auto-configures a simple thread pool, but override it with TaskExecutorConfigurer. Set max pool size, queue capacity, and rejection policy. Production patterns: core pool = 2-4, max = 4-8, queue = 100-500. Depending on your workload.
Parallel scheduling is powerful. Uncontrolled parallelism is a production incident waiting to happen. Tame it with configuration.
Report Generation Task Blocks All Other Schedulers
CompletableFuture.orTimeout().- Always configure a multi-threaded TaskScheduler.
- The single-thread default is a trap for any application with more than one scheduled task or any task that can take more than its interval to complete.
curl -s http://localhost:8080/actuator/scheduledtasks | jq '.cron[] | {runnable: .runnable.target, expression: .expression}'curl -s http://localhost:8080/actuator/scheduledtasks | jq '.fixedRate[]'Key takeaways
Common mistakes to avoid
6 patternsNot configuring a thread pool for the TaskScheduler
Using 5-field Unix cron syntax instead of Spring's 6-field format
CronExpression.parse() in unit testsNot catching exceptions in @Scheduled methods
Not using ShedLock in multi-instance (Kubernetes) deployments
Not specifying timezone in cron expressions
Using @Scheduled on classes not managed by Spring
Interview Questions on This Topic
What is the default thread pool size for Spring's @Scheduled tasks?
Frequently Asked Questions
That's Spring Boot. Mark it forged?
11 min read · try the examples if you haven't