How to Schedule Tasks with Spring Boot and @Scheduled
Published Updated Spring Boot 11 min read
fixedRate against fixedDelay, cron expressions with the seconds field, the single-threaded default that makes one slow job block every other, and what @Scheduled does when you run two instances.
@Scheduled is four characters of configuration away from working, which is why it so often ships
with two defects: every job runs on one thread, and every job runs on every instance. Neither shows
up on a laptop.
Written against Spring Boot 3.2 and Java 17.
Enable it first
Spring Boot does not turn scheduling on for you:
@Configuration
@EnableScheduling
public class SchedulingConfig { }
Without @EnableScheduling, @Scheduled methods are simply never called, no warning, no error, no
log line. If a job has never run once, check this before anything else.
The annotated method must return void and take no arguments, and it must live on a Spring bean.
Spring drives it through a proxy, which also means one @Scheduled method calling another
@Scheduled method in the same class bypasses the proxy entirely.
Fixed rate
@Component
public class ReportJobs {
private static final Logger log = LoggerFactory.getLogger(ReportJobs.class);
@Scheduled(fixedRate = 60_000)
public void publishQueueDepth() {
log.info("queue depth {}", queue.size());
}
}
fixedRate measures start to start. A run beginning at 12:00:00 schedules the next for
12:01:00 regardless of how long the first takes.
The consequence people meet in production, if the task takes longer than the interval, the next execution is already due when the current one finishes, so it starts immediately. Runs bunch up rather than spreading out. With the default single-threaded scheduler they queue rather than overlap, but the queue never drains.
Fixed delay
@Scheduled(fixedDelay = 60_000)
public void reconcileOrders() {
orderService.reconcileBatch();
}
fixedDelay measures end to start. One minute of quiet between runs, however long a run
takes. For anything whose duration varies with the amount of work. This is almost always the right
choice. Reconciliation, cleanup, polling: fixedDelay.
Use fixedRate when the cadence itself matters, sampling a value once a minute, and you have
confirmed the task finishes well inside the interval.
Initial delay
@Scheduled(fixedDelay = 30_000, initialDelay = 60_000)
public void warmCaches() { ... }
Without initialDelay, a job fires as soon as the context is ready, which is while connection
pools are still filling and caches are cold. A minute’s grace costs nothing and removes a class of
startup noise.
Cron
@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Berlin")
public void nightlyExport() { ... }
Spring’s cron has six fields, not five, it starts with seconds:
second minute hour day-of-month month day-of-week
0 0 3 * * * -> 03:00 every day
0 */15 * * * * -> every 15 minutes
0 0 9 * * MON-FRI -> 09:00 on weekdays
0 0 0 1 * * -> midnight on the 1st
Copying a five-field crontab line in makes it mean something else entirely: 0 0 3 * * is
rejected, and a five-field expression that does parse will fire at the wrong time.
Two things worth knowing. zone defaults to the server’s timezone, so an unqualified 3am job
moves twice a year under daylight saving, and skips or repeats on the changeover day; name the zone
explicitly, or use UTC. And Spring supports macros: @daily, @hourly, @midnight — plus
- to disable a job entirely, which is useful with a property:
@Scheduled(cron = "${jobs.export.cron:-}")
public void nightlyExport() { ... }
That job is off unless a cron expression is configured, which lets one artefact run the schedule in production and not in a developer’s environment.
The single-threaded default
This is the important one. Spring’s default TaskScheduler has a pool size of one. Every
@Scheduled method in the application shares that thread, so a job that takes ten minutes delays
every other job for ten minutes: including ones that appear to be on a completely different
schedule.
The symptom is a job that runs late, or seems to skip, with nothing wrong in its own code.
spring.task.scheduling.pool.size=5
spring.task.scheduling.shutdown.await-termination=true
spring.task.scheduling.shutdown.await-termination-period=30s
The shutdown settings matter as much as the pool size: without them, a deployment kills a job mid-write. With them, the context waits up to thirty seconds for running tasks to finish.
For finer control, define the scheduler:
@Bean
TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("sched-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
scheduler.setErrorHandler(t -> log.error("scheduled task failed", t));
return scheduler;
}
Name the threads. A stack trace from sched-3 tells you where to look; one from pool-2-thread-1
does not.
On Java 21 you can hand tasks to virtual threads instead, which removes pool sizing as a concern for jobs that are mostly waiting on I/O:
spring.threads.virtual.enabled=true
What happens when a task throws
An uncaught exception is passed to the scheduler’s error handler, which logs it, and the job stays scheduled. Later executions still run. That is a sensible default and it means a job can fail silently for weeks if nobody reads the log.
Make failure visible rather than relying on it being noticed:
@Scheduled(fixedDelay = 300_000)
public void reconcile() {
try {
reconciliation.run();
meterRegistry.counter("jobs.reconcile", "outcome", "ok").increment();
} catch (Exception e) {
meterRegistry.counter("jobs.reconcile", "outcome", "error").increment();
log.error("reconciliation failed", e);
}
}
A counter tagged with the outcome is something you can alert on. A log line is something you find afterwards.
Two instances run everything twice
@Scheduled is per JVM. Deploy three replicas and the nightly export runs three times, in
parallel, on the same data. This is the defect that ships most often, because staging usually runs
one instance.
There is no framework setting for it. The options are:
- A distributed lock. ShedLock is the common choice: each execution takes a lock in the shared database or Redis, and only the holder proceeds. Small dependency, minimal change to the method.
- Move the schedule out. A Kubernetes
CronJobor an external scheduler calls an endpoint or runs a one-shot container. The trigger lives in one place by construction. - A leader election, if you already have the infrastructure for one.
What is not an option is assuming a single replica forever.
Testing
Do not wait for real time to pass. Call the method directly for its logic, and assert the schedule separately:
@Test
void exportRunsAtThreeAm() {
CronExpression cron = CronExpression.parse("0 0 3 * * *");
LocalDateTime next = cron.next(LocalDateTime.of(2026, 3, 1, 0, 0));
assertThat(next).isEqualTo(LocalDateTime.of(2026, 3, 1, 3, 0));
}
CronExpression is the same parser Spring uses at runtime, so this catches a five-field expression
immediately.
Frequently asked questions
Why is my @Scheduled method never called?
Almost always a missing @EnableScheduling. After
that: the class is not a Spring bean, or the method takes arguments or returns a value. All three
fail silently.
What is the difference between fixedRate and fixedDelay?
fixedRate measures start to start;
fixedDelay measures end to start. If the task duration varies, fixedDelay is what you want.
Why does my cron expression not work?
Spring’s cron has six fields and begins with seconds. A five-field crontab line either fails to parse or means a different time.
Why is one job delaying another?
The default scheduler pool size is 1, so every scheduled method
in the application shares one thread. Set spring.task.scheduling.pool.size.
Does a scheduled task stop after it throws?
No. The exception is logged and future executions continue. Record an error counter so a repeatedly failing job is visible rather than buried.
How do I stop a job running on every instance?
Take a distributed lock (ShedLock or equivalent), or move the trigger out of the application into a CronJob or external scheduler. Nothing in Spring coordinates this for you.
Can I configure the interval from a property?
Yes, fixedDelayString = "${jobs.poll.delay}"
and cron = "${jobs.export.cron}". A cron value of - disables the job, which is a clean way to
enable it per environment.
How do I handle daylight saving?
Set zone explicitly on the annotation, or schedule in UTC. An
unqualified local-time job skips or repeats on the changeover day.
Can @Scheduled methods run concurrently?
Not the same method. Each is serialised on its schedule. Different methods run concurrently only if the pool is larger than one.
Should I use @Async with @Scheduled?
Rarely. It hands the work to a different executor and makes the schedule’s timing harder to reason about. Size the scheduler pool instead.
Where should I go next?
Actuator exposes the metrics these jobs should be recording, and Prometheus and Grafana turns a job’s error counter into an alert.