RizTech Academy logo
RizTech Academy
Concurrency, Transactions and AsyncLesson 4 of 525 min

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 @Async and @Scheduled fall 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

  1. Add @EnableScheduling and a @Scheduled(fixedRate=...) method that logs; confirm it fires on the interval.
  2. Compare fixedRate and fixedDelay with a deliberately slow method — observe overlap vs no-overlap.
  3. Write a cron expression for "every day at 6am" and one for "every 15 minutes"; confirm the timing.
  4. Reason through what happens to a @Scheduled daily job when you run 3 instances of the app — and why it is invisible in development.
  5. Describe how ShedLock (a distributed lock) makes a scheduled job run once across the cluster.
  6. 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

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