Spring @Scheduled — A Complete Deep-Dive
Spring Framework's `@Scheduled` annotation looks simple but has subtle behavior around timing, threading, transactions, and testing. This guide covers every parameter, the differences between Spring's cron and Quartz's cron, the patterns for unit-testing scheduled methods, and the common production pitfalls.
Updated
Setup
Enable scheduling in any @Configuration class:
@Configuration
@EnableScheduling
public class SchedulingConfig {}
Then annotate methods on any Spring-managed bean (@Component, @Service, etc.):
@Component
public class ReportJob {
@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "America/New_York")
public void sendDailyDigest() {
// ...
}
}
That’s it. Spring registers the method with its TaskScheduler at bean initialization and fires it on the configured schedule. The method must:
- Be public (not private/protected)
- Take no arguments
- Return void (or, for
@Asyncmethods,CompletableFuture/ListenableFuture) - Live on a bean managed by the Spring container
Methods that miss any of these criteria are silently skipped — no exception, no log warning. The classic “my @Scheduled method doesn’t fire” debug starts with verifying these four things.
Annotation parameters
@Scheduled has six trigger-style parameters. Pick exactly one (Spring throws an exception if you combine them):
cron — cron-style schedule. 6-field with seconds at the front:
@Scheduled(cron = "0 */15 * * * *") // every 15 minutes
@Scheduled(cron = "0 0 9 * * MON-FRI") // weekdays at 9 AM
@Scheduled(cron = "@hourly") // macro: every hour on the hour
See the Spring dialect reference for the full syntax.
fixedRate — milliseconds between invocation starts:
@Scheduled(fixedRate = 60000) // every 60 seconds, regardless of duration
If the previous invocation is still running when the next fire time arrives, the new invocation queues (single-threaded executor) or runs in parallel (multi-threaded). Use with care.
fixedRateString — same as fixedRate but accepts an expression (Duration string or property placeholder):
@Scheduled(fixedRateString = "PT5M") // every 5 minutes (ISO-8601)
@Scheduled(fixedRateString = "${app.report.rate}") // from properties
fixedDelay — milliseconds between previous-end and next-start:
@Scheduled(fixedDelay = 60000) // 60 seconds after each invocation completes
Use fixedDelay for jobs where you never want overlap. The delay starts from when the previous run finished, so a 5-minute job with fixedDelay = 60000 runs roughly every 6 minutes (5 minutes of work + 1 minute of delay).
fixedDelayString — same as fixedDelay with expression support.
initialDelay / initialDelayString — milliseconds to wait before the first invocation. Pairs with fixedRate or fixedDelay:
@Scheduled(initialDelay = 30000, fixedRate = 60000)
public void warmUpThenPoll() { ... }
zone — IANA timezone name. Only applies to cron-based schedules:
@Scheduled(cron = "0 0 9 * * *", zone = "America/Los_Angeles")
Without zone, schedules use the JVM’s default timezone. On cloud platforms that’s usually UTC; always set zone explicitly to avoid surprises.
Spring cron vs Quartz cron
Spring’s @Scheduled(cron = ...) is a Spring-specific cron parser — not the same as Quartz’s, even though both have 6 fields. Differences:
| Feature | Spring | Quartz |
|---|---|---|
| Fields | 6 (no year) | 6 or 7 (year optional) |
| Sunday | 0 or 7 (so Mon=1) | 1 (so Mon=2) |
? placeholder | Optional (accepted, not required) | Mandatory in DoM or DoW |
L (last) | DoM and DoW | DoM and DoW |
W (nearest weekday) | Supported | Supported |
# (nth weekday) | Supported | Supported |
Macros (@hourly, etc.) | ✓ (5.3+) | — |
Since Spring 5.3, the special characters L, W, and # behave the same in both — so the real gotchas when porting a Quartz expression are the day-of-week numbering and the year field:
- Day-of-week is off by one. Quartz uses Sunday =
1, so0 0 9 * * 2means Monday in Quartz but Tuesday in Spring (where Sunday =0). Prefer the name forms (MON,TUE, …), which are unambiguous in both. - The year field doesn’t exist in Spring. A 7-field Quartz expression like
0 0 9 * * ? 2027fails to parse in Spring, which only accepts 6 fields. - Spring accepts Quartz’s
?, so0 0 9 ? * MON-FRIdoes work in Spring — it’s treated the same as*.
If you specifically want Quartz semantics (because you’re integrating with a system that uses them), use Spring’s Quartz integration — @Scheduled is its own parser.
See the dialect comparison guide for the full breakdown.
Threading and the default executor
By default, Spring uses a single-threaded ScheduledExecutorService for all @Scheduled methods. Every scheduled method on every bean shares one thread. Consequences:
- If one method takes 30 seconds, every other scheduled method blocks for 30 seconds
- Overlapping fires of the same
fixedRatemethod queue behind each other - A scheduled method that throws an exception… kills the executor in old Spring versions (modern versions catch it but log noisily)
For non-trivial work, define a TaskScheduler bean with a proper thread pool:
@Configuration
@EnableScheduling
public class SchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setThreadNamePrefix("scheduled-");
scheduler.setErrorHandler(t ->
log.error("Scheduled method failed", t));
scheduler.initialize();
registrar.setTaskScheduler(scheduler);
}
}
With a pool of 10, ten scheduled methods can run concurrently. Past that, work queues. Set the pool size based on your actual scheduled-method count and their typical durations.
For per-method async dispatch (each invocation runs on a separate thread, no queuing), combine @Scheduled with @Async:
@Configuration
@EnableScheduling
@EnableAsync
public class Config {}
@Component
public class LongRunningJob {
@Async
@Scheduled(cron = "0 0 0 * * *")
public void nightlyImport() {
// runs on the Async executor, not blocking the scheduler thread
}
}
Feature-flag-controlled jobs
A common need: enable a scheduled job in some environments but not others (e.g., disable email-sending in dev). Use @ConditionalOnProperty:
@Component
@ConditionalOnProperty(value = "app.scheduling.daily-digest.enabled", havingValue = "true")
public class DailyDigestJob {
@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "America/New_York")
public void send() { ... }
}
In application.yml:
app:
scheduling:
daily-digest:
enabled: false # dev
Or per-environment with profile-specific properties:
# application-prod.yml
app:
scheduling:
daily-digest:
enabled: true
This is cleaner than if (env.equals("prod")) { ... } inside the method body — the whole bean is excluded from the context in environments where the property is false, so the scheduled method never gets registered.
For dynamic on/off without redeploy, store the flag in a feature-flag service and check it at the top of the method:
@Scheduled(cron = "0 */15 * * * *")
public void poll() {
if (!featureFlags.isEnabled("polling-job")) return;
// ...
}
Testing scheduled methods
Testing the method itself is easy — it’s just a method:
@SpringBootTest
class ReportJobTest {
@Autowired ReportJob job;
@Test
void sendDailyDigest_sendsToAllSubscribers() {
job.sendDailyDigest();
// assert side effects
}
}
Testing that the schedule itself fires correctly is harder. Three approaches:
1. Verify the schedule is registered. Inject ScheduledAnnotationBeanPostProcessor and assert the registrar has the task:
@SpringBootTest
class SchedulingConfigTest {
@Autowired ScheduledTaskHolder taskHolder;
@Test
void dailyDigestIsScheduled() {
Set<ScheduledTask> tasks = taskHolder.getScheduledTasks();
boolean found = tasks.stream()
.anyMatch(t -> t.toString().contains("sendDailyDigest"));
assertThat(found).isTrue();
}
}
2. Disable real scheduling, drive the method manually. Use @MockBean or @TestConfiguration to override the scheduler bean with a no-op, then call the method directly in the test.
3. Speed up time. Mock a Clock bean (if your code uses java.time.Clock) and have the test inject one that ticks forward on demand. Combine with a deterministic scheduler for end-to-end tests of scheduling behavior.
For most production code, approach #1 (verify registration) + unit-testing the method body is enough. The scheduler is Spring’s responsibility; you just need to confirm your method is wired up correctly.
Common pitfalls
The method must be public. A private or protected @Scheduled method is silently ignored. No log warning in most versions.
The bean must be Spring-managed. Annotations on plain classes (not @Component/@Service/@Configuration) don’t register. Annotations on inner classes of beans… it depends; usually they don’t.
@Transactional + @Scheduled interactions. The schedule fires outside any transaction. If you annotate the method with @Transactional, Spring’s proxy intercepts the call. But Spring’s proxy doesn’t fire for self-invocations — so a @Transactional method called from within the same bean’s scheduled method may not actually start a transaction. Either move the transactional logic to a separate bean or use AspectJ weaving instead of proxy-based AOP.
Default executor is single-threaded. A 30-second scheduled method blocks every other scheduled method for 30 seconds. Configure a thread pool.
Default timezone is JVM-default. Pin zone on every cron-based @Scheduled. JVM-default-as-UTC happens on cloud containers, but “default” is a configuration choice you have to remember.
Cron syntax is Spring’s, not Quartz’s. Pasting Quartz expressions into Spring’s cron parameter is the most common scheduling bug in Spring apps. Use the parser with the Spring dialect selected to verify.
Methods can’t take arguments. If you need configuration per-invocation, inject collaborators via Spring’s DI and call them from the no-arg method.
One method, many schedules. A method can have multiple @Scheduled annotations via @Schedules:
@Schedules({
@Scheduled(cron = "0 0 9 * * MON-FRI"),
@Scheduled(cron = "0 0 13 * * MON-FRI"),
})
public void weekdayTwice() { ... }
Both fire times invoke the same method.
Frequently asked questions
Why doesn't my @Scheduled method run?
What's the difference between fixedRate and fixedDelay?
Is Spring's @Scheduled cron the same as Quartz's?
How do I run a @Scheduled method in a specific timezone?
How many scheduled methods can run concurrently in Spring?
Related
Every 5 Minutes
`0 */5 * * * *` — common @Scheduled cron value.
PatternEvery 5 Seconds
`*/5 * * * * *` — sub-minute @Scheduled, only possible with Spring's seconds field.
PatternEvery 30 Seconds
`*/30 * * * * *` — twice-a-minute Spring scheduling.
PatternDaily at Midnight
`0 0 0 * * *` — nightly Spring scheduled jobs.
ToolCron Parser
Verify your @Scheduled expression with the Spring dialect selected.
GuideCron Dialect Comparison
How Spring's cron differs from Unix, Quartz, Kubernetes, and others.
GuideKubernetes CronJob
Run scheduled Spring work as a K8s CronJob for SLA-grade reliability.