Scheduled jobs with @Scheduled
Some work is triggered not by a request but by time: expire stale parcel holds every hour, generate a
daily delivery report at 6am, retry failed notifications every few minutes. Spring's @Scheduled runs a
method on a timer, with no request involved. It is simple to use and has one big gotcha that bites the
moment you run more than one instance of your app. This lesson covers both.
@Scheduled: run a method on a timer
Enable scheduling with @EnableScheduling on a configuration class, then annotate methods:
@Component
public class ParcelJobs {
@Scheduled(fixedRate = 60000) // every 60 seconds (from start to start)
public void retryFailedNotifications() { ... }
@Scheduled(fixedDelay = 30000) // 30 seconds AFTER the previous run finishes
public void cleanupTempData() { ... }
@Scheduled(cron = "0 0 6 * * *") // every day at 06:00 (a cron expression)
public void generateDailyReport() { ... }
}
Three ways to schedule, and the difference matters:
fixedRate— run every N ms measured start to start, regardless of how long each run takes. If a run overruns the interval, the next may start immediately (or overlap — see below).fixedDelay— wait N ms after the previous run finishes before starting the next. Runs never overlap; the gap is between end and next start. Use this when a run must complete before the next begins.cron— a cron expression for calendar-based schedules ("every day at 6am", "every Monday"). Spring's cron has six fields (second, minute, hour, day-of-month, month, day-of-week).
Choose fixedDelay for "do this repeatedly, never overlapping", fixedRate for "every N seconds on a
steady beat", and cron for "at these specific times". The scheduled method takes no arguments (there is no
caller to pass them) and typically calls a service to do the work.
Scheduled methods run on one thread by default
A subtlety: by default Spring runs all @Scheduled methods on a single thread. So if one scheduled job
runs long, it delays the others (they queue behind it). For independent jobs, configure a scheduling thread
pool (a ThreadPoolTaskScheduler bean, or spring.task.scheduling.pool.size) so they do not block each
other. Also, a long-running scheduled task on a fixedRate schedule can pile up; fixedDelay avoids that by
construction. Know that the default is single-threaded scheduling and size a pool if you have several jobs.
The big gotcha: multiple instances run the job multiple times
Here is the mistake that reaches production the moment you scale out. @Scheduled runs in every running
instance of your application. If you deploy Dakiya across three instances (for availability or load),
each instance's scheduler fires generateDailyReport() at 6am — so the report is generated three times.
For a report that is wasteful; for "charge every customer their monthly bill" it is a disaster — every
customer charged three times.
This catches people because it is invisible in development (one instance) and only appears in production (multiple instances). The fixes:
- A distributed lock — a library like ShedLock lets only one instance run a given scheduled task at a time, using a shared lock (in the database or Redis). The standard fix for "run this scheduled job once across the cluster".
- A dedicated scheduler / external trigger — run scheduled jobs from a single dedicated instance, or trigger them externally (a cron service, a job scheduler) that calls one endpoint.
- A durable job system (Quartz clustered, or a proper job queue) — for serious scheduling needs, a system built to coordinate across instances.
The rule to internalise: @Scheduled alone is safe only on a single instance; the moment you run more than
one, you need a distributed lock (ShedLock) or an external scheduler, or the job runs once per instance.
This is the concurrency lesson of scheduling — the "concurrency" is between your own app's copies.
Scheduling versus async versus a job queue
Placing @Scheduled among the module's tools:
@Async— offload request-triggered work off the request thread (best-effort, in-memory).@Scheduled— run time-triggered work on a timer (with the multi-instance caveat).- A durable job queue — for work that must not be lost, must be retried, or must be coordinated across
instances — the reliable option both
@Asyncand@Scheduledfall short of when durability matters.
@Scheduled is perfect for simple periodic maintenance on a single instance (or with ShedLock across a
cluster): cleanups, refreshes, periodic reports. When the scheduled work is critical and must run exactly
once and survive failures, reach for a clustered scheduler or job system. As with @Async, the judgement is
whether losing or duplicating a run is acceptable — for a cache refresh, fine; for anything touching money or
required records, use the coordinated, durable option.
Check your work
@Scheduled. Enable with @EnableScheduling; run a method on a timer via fixedRate (start-to-start),
fixedDelay (gap after previous finishes, never overlaps), or cron (calendar times). Method takes no
arguments.
Choosing the timer. fixedDelay for non-overlapping repeated work, fixedRate for a steady beat, cron
for specific times.
Single-threaded by default. All scheduled methods share one thread; a long job delays others — configure a scheduling pool if you have several.
The multi-instance gotcha. @Scheduled runs in every instance, so scaling to N instances runs the job
N times — wasteful at best, disastrous (N-times charges) at worst. Invisible in dev, appears in production.
The fixes. A distributed lock (ShedLock — run once across the cluster), a dedicated scheduler/external trigger, or a clustered job system for critical scheduling.
Placing it. @Async (request-triggered), @Scheduled (time-triggered), durable job queue (must-not-lose/
coordinated) — use @Scheduled for simple periodic maintenance; a coordinated durable option for critical
runs.
Practice
- Add
@EnableSchedulingand a@Scheduled(fixedRate=...)method that logs; confirm it fires on the interval. - Compare
fixedRateandfixedDelaywith a deliberately slow method — observe overlap vs no-overlap. - Write a
cronexpression for "every day at 6am" and one for "every 15 minutes"; confirm the timing. - Reason through what happens to a
@Scheduleddaily job when you run 3 instances of the app — and why it is invisible in development. - Describe how ShedLock (a distributed lock) makes a scheduled job run once across the cluster.
- For three time-triggered Dakiya jobs (cache refresh, daily report, monthly billing), decide plain
@Scheduled,@Scheduled+ ShedLock, or a durable scheduler — and justify each.
Official documentation
- Spring — @Scheduled —
fixedRate,fixedDelay,cron. - Spring — Cron expressions — The six-field format.
- ShedLock — Running a scheduled task once across multiple instances.
Next: reactive Spring (WebFlux) — what it is, and when not to use it.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship