How does @Scheduled work, and what are its limitations in a multi-instance deployment?

8 minadvancedscheduledcrondistributed-scheduling

Quick Answer

@Scheduled, combined with @EnableScheduling, runs an annotated method automatically on a fixed schedule — via a fixed rate, a fixed delay, or a cron expression — using a task scheduler managed by Spring, with no external scheduling infrastructure needed for a single instance. Its major limitation: if the application is deployed as multiple instances (for scaling or high availability), every instance runs the same @Scheduled method independently and simultaneously by default, which is usually wrong for tasks that should run exactly once across the whole fleet — requiring an external coordination mechanism (a distributed lock via ShedLock, or delegating scheduling to an external, single-writer system) to avoid duplicate execution.

Detailed Answer

@Scheduled, combined with @EnableScheduling, lets a method run automatically on a defined schedule, without any external cron daemon or scheduling infrastructure — Spring manages the timing internally via a TaskScheduler:

@Configuration
@EnableScheduling
class SchedulingConfig { }

@Component
class ReportJob {
    @Scheduled(fixedRate = 60000)        // every 60 seconds, measured from start-to-start
    void runEveryMinute() { ... }

    @Scheduled(fixedDelay = 60000)       // 60 seconds after the *previous run finished*
    void runWithDelay() { ... }

    @Scheduled(cron = "0 0 2 * * *")     // cron expression — every day at 2:00 AM
    void runNightlyReport() { ... }
}

fixedRate measures the interval between successive start times (so if a run takes longer than the interval, the next one starts immediately after the previous finishes, rather than overlapping); fixedDelay measures the interval from one run's completion to the next run's start.

The major limitation: multi-instance deployments. @Scheduled has no built-in awareness of other instances of the same application running elsewhere. If the application is scaled horizontally to, say, 4 instances for availability/load reasons, all 4 instances will independently trigger the same @Scheduled method at the same scheduled time — which is almost never the intended behavior for a task that should logically run exactly once across the whole fleet (e.g., "generate today's single daily report," "send one batch of reminder emails," not four duplicate copies of each).

Common solutions:

  1. A distributed lock library like ShedLock — wraps the scheduled method so that, across all instances, only whichever one acquires the lock first actually executes the method for that scheduled trigger; the others detect the lock is held and simply skip that run:
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "nightlyReport", lockAtMostFor = "PT30M")
void runNightlyReport() { ... }
  1. Delegate scheduling to a single, external, dedicated trigger source (a Kubernetes CronJob that invokes exactly one instance/endpoint, an external workflow scheduler) instead of relying on @Scheduled running independently inside every replica of the application.

  2. Restrict the scheduled logic to run only on a designated "leader" instance, using a leader-election mechanism — more complex to set up than ShedLock for most cases, but sometimes appropriate in systems that already have leader election available for other reasons.

Practical guidance: @Scheduled alone is fine for a single-instance deployment, or for scheduled work that's genuinely safe to run redundantly on every instance (e.g., refreshing an in-memory, per-instance cache) — but the moment a task must run exactly once across a horizontally-scaled fleet, @Scheduled by itself is the wrong tool without adding one of these coordination mechanisms on top.