Mid 11 min · May 23, 2026

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.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • @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
✦ Definition~90s read
What is Spring @Scheduled?

Spring's @Scheduled annotation marks a method to be called on a schedule managed by Spring's TaskScheduler. When @EnableScheduling is on the application class (or any @Configuration class), Spring registers all @Scheduled methods during context initialization and submits them to the task scheduler.

Spring @Scheduled is like setting a kitchen timer.

Three scheduling modes exist: fixedRate (fire every N milliseconds regardless of task duration), fixedDelay (wait N milliseconds after the previous execution completes before firing again), and cron (fire at specific times expressed as a 6-field cron expression). The mode affects overlap behavior and timing semantics significantly.

By default, Spring creates a single-threaded TaskScheduler. All @Scheduled methods across your entire application share this one thread. This means if any scheduled task runs longer than its interval, subsequent invocations queue up on that single thread. In the worst case, a hanging task blocks all other scheduled tasks indefinitely.

Plain-English First

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.

An unhandled exception kills the scheduled task permanently
If a @Scheduled method throws an uncaught exception, Spring logs it and does not reschedule that task. It's gone until the application restarts. Always wrap @Scheduled method bodies in try-catch, or configure a global ErrorHandler on ThreadPoolTaskScheduler. This is the most common reason 'my scheduled task stopped running' happens silently.
Production Insight
Set setWaitForTasksToCompleteOnShutdown(true) and setAwaitTerminationSeconds(60) on your ThreadPoolTaskScheduler. Without it, in-progress scheduled tasks are killed immediately on shutdown, potentially leaving data in inconsistent state.
Key Takeaway
Default single-thread scheduler is a production trap. Always configure ThreadPoolTaskScheduler with pool size >= concurrent task count. Wrap task bodies in try-catch — an exception permanently kills the schedule.

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.

Spring cron has 6 fields — seconds is first
Standard Unix cron: 'minute hour day month weekday' (5 fields). Spring cron: 'second minute hour day month weekday' (6 fields). '0 30 9 MON' = 9:30:00 AM Monday. If you use 5-field syntax, Spring throws an exception at startup. Always specify seconds as the first field.
Production Insight
In Docker/Kubernetes, the JVM timezone is UTC unless you set TZ environment variable. Always specify zone on @Scheduled(cron) — don't rely on the JVM default timezone matching your business timezone.
Key Takeaway
Spring cron is 6 fields: second minute hour day month weekday. Always specify zone='Your/Timezone' to avoid DST bugs. Validate cron expressions in @PostConstruct to catch syntax errors at startup.

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.

Use String variants to make schedules configurable
fixedRateString, fixedDelayString, and the zone attribute of cron all accept ${property.key} expressions. This lets you configure schedules without recompiling. Essential for environments where dev runs tasks more frequently (every 30s) and production runs them less often (every 5 minutes).
Production Insight
Always use fixedDelay for tasks that interact with external systems (databases, APIs, queues). fixedRate can cause thundering herd problems where multiple task instances overlap during periods of external system slowness.
Key Takeaway
fixedDelay = wait N ms after last completion (no overlap possible). fixedRate = fire every N ms from last start (overlap possible with thread pools). Use fixedDelay for stateful/sequential tasks, fixedRate for stateless metrics/sampling.

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.

Without distributed locking, every pod runs every scheduled task
A 3-replica deployment with a @Scheduled email sender fires 3 emails per invocation. A nightly database cleanup job deletes records 3 times. ShedLock (database or Redis backed) is the standard solution. Add it to any Spring Boot application that runs with more than one instance.
Production Insight
Set lockAtMostFor to 2-3x the maximum expected task duration. If the task normally takes 5 minutes but can take 20 under heavy load, set lockAtMostFor to 60 minutes. A crashed node releases the lock after this time — too short means another node re-runs the task, too long means long delays after a crash.
Key Takeaway
Kubernetes replicas mean every @Scheduled task runs on every pod. Use ShedLock with a database or Redis lock provider to ensure only one node executes cluster-wide tasks.

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.

Use @Scheduled property expressions for environment-specific intervals
@Scheduled(fixedRateString = '${jobs.sync.rate-ms:60000}') reads the interval from application properties with a default of 60 seconds. Set jobs.sync.rate-ms=5000 in application-dev.properties for faster iteration in development. This simple pattern handles 90% of 'different interval per environment' requirements.
Production Insight
We expose a secured admin endpoint to pause/resume scheduled jobs during planned maintenance windows. This is much safer than shutting down instances — you can pause data-syncing jobs while keeping the API available to serve read traffic.
Key Takeaway
For dynamic intervals: SchedulingConfigurer with Trigger reads configuration at each invocation. For runtime on/off control: 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.

Track last successful run time, not just exception count
A task can stop silently without throwing exceptions — a thread pool exhaustion causes tasks to queue indefinitely without errors. Track last successful execution timestamp and alert when it exceeds 2-3x the schedule interval. This catches both exception-based failures and blocked-thread failures.
Production Insight
We run a dead-man's switch pattern: each critical scheduled job posts a heartbeat to an external monitoring service (like Healthchecks.io). If the heartbeat doesn't arrive within the expected window, we get paged. This catches ALL failure modes — not just exceptions, but also blocked threads, pod crashes, and JVM freezes.
Key Takeaway
Track last successful execution timestamp, expose it as a health indicator, and set up alerts for overdue tasks. A task silently not running is worse than a task that fails loudly.

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.

SchedulingConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
// io.thecodeforge — java tutorial
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class SchedulingConfig {
    // That's it. One annotation. 
    // Spring Boot will scan for @Scheduled methods 
    // in all beans within this config's component scan.
}
Output
// No output — this is configuration.
// But your scheduled tasks will finally run.
Production Trap:
If you have multiple @Configuration classes, only one needs @EnableScheduling. Adding it twice does nothing harmful, but if you put it on a class outside your component scan, nothing happens. Verify with DEBUG logging for org.springframework.scheduling — you'll see 'Initializing ExecutorService' or nothing at all.
Key Takeaway
Always add @EnableScheduling to a configuration class before writing any @Scheduled method. This is the single most common 'why isn't my task running?' fix.

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.

ParameterizedScheduler.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — java tutorial
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class ParameterizedScheduler {

    // application.yml: 
    // scheduler:
    //   cleanup-rate: 5000
    //   cleanup-cron: "0 0/15 * * * ?"

    @Scheduled(fixedRateString = "${scheduler.cleanup-rate:10000}")
    public void cleanupOldRecords() {
        System.out.println("Cleanup task running every " + 
            System.getProperty("scheduler.cleanup-rate", "10000") + "ms");
    }

    @Scheduled(cron = "${scheduler.cleanup-cron:0 0 * * * ?}")
    public void nightlyCleanup() {
        System.out.println("Nightly cleanup triggered via cron");
    }
}
Output
// If application.yml has scheduler.cleanup-rate: 3000
// Output every 3 seconds:
Cleanup task running every 3000ms
Cleanup task running every 3000ms
...
Pro Tip:
Use fixedRateString and fixedDelayString (String variants) instead of fixedRate and fixedDelay. The String variants pull from properties. The long variants are for hardcoded values. The String suffix is your gateway to environment-aware scheduling.
Key Takeaway
Never hardcode timing values. Use @Scheduled(fixedRateString = "${path.to.property:default}") and keep your schedule configurable per environment.

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.

ParallelScheduledTask.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — java tutorial
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class ParallelScheduledTask {

    @Async
    @Scheduled(fixedRate = 1000)
    public void processFileBatch() {
        System.out.println(Thread.currentThread().getName() + 
            " — processing batch at " + System.currentTimeMillis());
        // Simulating 2-second work
        try { Thread.sleep(2000); } catch (InterruptedException e) {}
    }

    // Without @Async, this runs sequentially — next run waits 2 seconds
    // With @Async, each invocation gets a thread from the pool
}
Output
// With @Async and a 4-thread pool, you see:
// task-1 — processing batch at 1700000001000
// task-2 — processing batch at 1700000002000
// task-3 — processing batch at 1700000003000
// task-4 — processing batch at 1700000004000
// task-1 — processing batch at 1700000005000
// ... Every second a new task starts, even if previous ones are still running
Production Trap:
Never use @Async with @Scheduled without configuring the async executor. Default Spring Boot config creates a new thread per task with no bounds. One misbehaving scheduled task can spawn thousands of threads. Always override with spring.task.execution.pool properties or a custom AsyncConfigurer.
Key Takeaway
Add @Async to fixedRate tasks that can overlap, but always configure a bounded thread pool. Default unbounded pool = production disaster.
● Production incidentPOST-MORTEMseverity: high

Report Generation Task Blocks All Other Schedulers

Symptom
At 2 AM on the 1st of the month, the monthly-report job started. Cache warmup (scheduled for 2:30 AM), heartbeat pings (every 60 seconds), and session cleanup (every 5 minutes) all stopped. Monitoring showed zero scheduled task executions after 2:00 AM. The report finished at 4:17 AM and all other jobs resumed. The gap wasn't noticed until a morning dashboard showed stale data.
Assumption
The developer assumed @Scheduled tasks run on separate threads, similar to how Linux cron spawns a new process per job.
Root cause
Default Spring TaskScheduler uses a single thread (ThreadPoolTaskScheduler with pool size 1). All @Scheduled methods compete for this one thread. The 2+ hour report job held the thread and blocked all others.
Fix
Added a ThreadPoolTaskScheduler bean with pool size 10. Added @Async to the monthly report job method (combined with @EnableAsync) so it releases the scheduler thread immediately and runs on the async thread pool. Added individual timeout guards to each scheduled job using CompletableFuture.orTimeout().
Key lesson
  • 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.
Production debug guideSymptom → root cause → fix for common @Scheduled failures4 entries
Symptom · 01
Scheduled tasks are not running at all
Fix
First verify @EnableScheduling is on a @Configuration class in the application context. Then check that the class with @Scheduled is actually a Spring bean (annotated with @Component, @Service, etc.) — if instantiated with 'new', scheduling is silently skipped. Enable DEBUG logging for 'org.springframework.scheduling' to see task registration. Check if an exception in @PostConstruct or context initialization is preventing the scheduler from starting.
Symptom · 02
Tasks run on startup but stop after some time
Fix
An uncaught exception in a @Scheduled method kills that task permanently — Spring logs the error and does not reschedule it. Check application logs for 'Unexpected error occurred in scheduled task' around the time tasks stopped. Fix the underlying exception and restart. Consider wrapping the entire method body in try-catch to prevent a single failure from killing the schedule permanently. Also check if the scheduler thread pool is exhausted (all threads blocked on slow tasks).
Symptom · 03
Cron job runs at wrong time
Fix
Spring cron has 6 fields (second minute hour day month weekday), not 5 like Unix cron. '0 0 9 MON' means 9:00:00 AM every Monday. If you omit the seconds field or use 5-field syntax, Spring throws or misinterprets. Also check timezone — without 'zone' attribute, Spring uses the JVM default timezone which may differ from your expected timezone. Use @Scheduled(cron = '0 0 9 MON', zone = 'America/New_York') to be explicit.
Symptom · 04
In a cluster, the same task runs on all nodes simultaneously causing duplicate processing
Fix
Spring has no built-in distributed scheduling. Every node in your cluster independently runs every @Scheduled task. Add ShedLock (a distributed lock library) with @SchedulerLock on your methods. ShedLock uses a shared database or Redis to ensure only one node executes a scheduled task at a time. Alternatively, use Spring Batch for stateful batch jobs or Spring Cloud Task for distributed task execution.
★ Scheduler Debug Cheat SheetCommands to investigate scheduling issues
Check if scheduled tasks are registered
Immediate action
Enable scheduled task actuator endpoint
Commands
curl -s http://localhost:8080/actuator/scheduledtasks | jq '.cron[] | {runnable: .runnable.target, expression: .expression}'
curl -s http://localhost:8080/actuator/scheduledtasks | jq '.fixedRate[]'
Fix now
If task not listed, check @EnableScheduling is present and the class is a Spring bean
Check thread pool utilization for scheduler+
Immediate action
Hit the metrics endpoint for scheduler threads
Commands
curl -s http://localhost:8080/actuator/metrics/executor.pool.size | jq '.measurements'
curl -s 'http://localhost:8080/actuator/metrics/executor.active?tag=name:taskScheduler' | jq '.measurements'
Fix now
If active == pool size, all threads are busy — tasks are queuing. Increase thread pool size
Verify cron expression fires at expected time+
Immediate action
Test cron expression locally
Commands
java -cp '.' -e "import org.springframework.scheduling.support.*; var c = new CronExpression('0 30 9 * * MON-FRI'); System.out.println(c.next(java.time.LocalDateTime.now()));"
curl -s 'http://localhost:8080/actuator/scheduledtasks' | jq '.cron[] | select(.expression | contains("MON")) | .nextExecutionTime'
Fix now
Use https://crontab.guru (note: add leading second field for Spring) or CronExpression.next() to validate
Scheduling Approaches Comparison
ApproachUse CaseDistributed?Dynamic?Complexity
@Scheduled fixedDelaySequential polling, no overlap needed❌ (runs on all nodes)Via propertiesLow
@Scheduled fixedRateRegular sampling, stateless tasks❌ (runs on all nodes)Via propertiesLow
@Scheduled cronTime-based business jobs❌ (runs on all nodes)Via propertiesLow
@Scheduled + ShedLockCluster-safe stateful jobs✅ DB/Redis lockVia propertiesMedium
SchedulingConfigurerDynamic interval from DB/config❌ (all nodes)✅ Runtime readMedium
TaskScheduler APIProgrammatic runtime control❌ (all nodes)✅ Full controlMedium
Quartz SchedulerComplex workflows, misfire handling✅ JDBC JobStore✅ Full controlHigh
Spring Batch + SchedulerStateful batch processing✅ Built-inLimitedHigh

Key takeaways

1
Spring's default scheduler is single-threaded
all @Scheduled tasks queue up on one thread. Always configure a ThreadPoolTaskScheduler with sufficient pool size for production.
2
An uncaught exception in @Scheduled permanently kills that task
no more executions until restart. Wrap task bodies in try-catch and log without rethrowing.
3
Spring cron has 6 fields (second first), not 5 like Unix cron. Always specify zone to avoid DST timezone bugs in containerized environments.
4
In multi-instance Kubernetes deployments, every @Scheduled task runs on every pod. Use ShedLock with a database or Redis lock to ensure cluster-wide tasks run exactly once per interval.
5
fixedDelay guarantees no overlap between invocations. fixedRate maintains consistent fire intervals but can cause overlap with thread pools. Choose based on whether task statefulness requires sequential execution.

Common mistakes to avoid

6 patterns
×

Not configuring a thread pool for the TaskScheduler

Symptom
One slow task blocks all other scheduled tasks; nightly jobs cascade into each other
Fix
Define a ThreadPoolTaskScheduler bean with pool size >= number of concurrent tasks expected. Enable async execution for long-running tasks with @Async
×

Using 5-field Unix cron syntax instead of Spring's 6-field format

Symptom
IllegalArgumentException at startup 'Cron expression must consist of 6 fields', or cron fires at unexpected times
Fix
Add seconds as the first field: '0 30 9 MON-FRI' not '30 9 MON-FRI'. Validate with CronExpression.parse() in unit tests
×

Not catching exceptions in @Scheduled methods

Symptom
One failed invocation kills the scheduled task permanently — no more executions until restart
Fix
Wrap the entire method body in try-catch. Log the exception, record failure metrics, but don't rethrow. Configure a global ErrorHandler on ThreadPoolTaskScheduler as a safety net
×

Not using ShedLock in multi-instance (Kubernetes) deployments

Symptom
Daily emails sent 3 times, nightly reconciliation runs 3 times, batch jobs process records in duplicate
Fix
Add ShedLock dependency + lock table + @SchedulerLock annotation on tasks that must run exactly once per schedule interval across the cluster
×

Not specifying timezone in cron expressions

Symptom
Scheduled jobs fire at wrong times — off by hours depending on DST; '9 AM daily' job fires at 4 AM or 5 AM local time in Docker containers (which run UTC)
Fix
Always use zone attribute: @Scheduled(cron = '0 0 9 *', zone = 'America/New_York'). Set TZ environment variable in Docker as a backstop
×

Using @Scheduled on classes not managed by Spring

Symptom
Scheduled methods never fire — no error, no log, complete silence
Fix
Ensure the class is annotated with @Component, @Service, @Repository, or returned from a @Bean method. Classes instantiated with 'new' are not managed by Spring and @Scheduled has no effect
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the default thread pool size for Spring's @Scheduled tasks?
Q02JUNIOR
What's the difference between fixedRate and fixedDelay?
Q03JUNIOR
What happens if a @Scheduled method throws an uncaught exception?
Q04SENIOR
How do you prevent a scheduled task from running on every node in a clus...
Q05JUNIOR
How many fields does a Spring cron expression have, and how does it diff...
Q06SENIOR
How do you change a scheduled task's interval at runtime without restart...
Q07SENIOR
How do you ensure a scheduled task doesn't overlap with itself (i.e., if...
Q08SENIOR
What's the risk of not specifying a timezone in cron @Scheduled expressi...
Q01 of 08JUNIOR

What is the default thread pool size for Spring's @Scheduled tasks?

ANSWER
1 — Spring uses a single-threaded TaskScheduler by default. All @Scheduled methods across the entire application share this one thread. If any task takes longer than its interval, subsequent invocations queue up and fire late. Always configure a ThreadPoolTaskScheduler bean with an appropriate pool size for production applications.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I use @Scheduled without @EnableScheduling?
02
Can I use @Scheduled on a method in a @RestController?
03
Does @Scheduled work with Spring WebFlux?
04
How do I run a task once on application startup and then on a schedule?
05
What's the difference between @Scheduled and Quartz Scheduler?
🔥

That's Spring Boot. Mark it forged?

11 min read · try the examples if you haven't

Previous
Autowiring in Spring Boot
18 / 21 · Spring Boot
Next
@Async and Async Processing in Spring Boot