Post

Scheduling in Spring Boot — @Scheduled, Quartz, and Distributed Locks

Master job scheduling in Spring Boot. Covers @Scheduled (fixedRate, fixedDelay, cron), thread pool configuration, ShedLock for distributed environments, Quartz for complex workflows, and patterns to avoid duplicate execution across instances.

Scheduling in Spring Boot — @Scheduled, Quartz, and Distributed Locks

The Problem: Duplicate Jobs in Clustered Deployments

You deploy two instances of your Spring Boot app behind a load balancer. Both have @Scheduled(cron = "0 0 2 * * *") to generate a daily report. What happens at 2 AM?

Both instances execute the job. Two reports. Two emails. Two database writes.

This is the #1 surprise in production scheduling. Spring @Scheduled is instance-local — it has no awareness of other instances.


@Scheduled Basics

The simplest scheduling in Spring Boot. Add @EnableScheduling and annotate methods:

1
2
3
4
5
6
7
@SpringBootApplication
@EnableScheduling
public class SchedulingApplication {
    public static void main(String[] args) {
        SpringApplication.run(SchedulingApplication.class, args);
    }
}

fixedRate vs fixedDelay vs Cron

AttributeBehaviorExampleUse Case
fixedRateNext run starts N ms after previous start@Scheduled(fixedRate = 60000)Periodic polling (even if previous is still running)
fixedDelayNext run starts N ms after previous completion@Scheduled(fixedDelay = 30000)Dependent tasks (must finish before next starts)
cronUnix-style cron expression@Scheduled(cron = "0 0 2 * * *")Time-specific execution (daily at 2 AM)
initialDelayDelay before first execution@Scheduled(fixedRate=5000, initialDelay=10000)Wait for app warmup

Cron Expression Cheat Sheet

ExpressionMeaning
0 0 2 * * *Daily at 2:00 AM
0 */15 * * * *Every 15 minutes
0 0 9-17 * * MON-FRIEvery hour 9 AM–5 PM, weekdays
0 0 0 1 * *First day of every month at midnight
0 30 6 * * MONEvery Monday at 6:30 AM

Format: second minute hour day-of-month month day-of-week


Thread Pool Configuration: The Default Single Thread Trap

By default, Spring Boot uses a single-threaded scheduler. If one task takes 5 minutes, all other scheduled tasks are blocked for 5 minutes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class SchedulerConfig {

    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5);
        scheduler.setThreadNamePrefix("scheduled-task-");
        scheduler.setErrorHandler(throwable ->
                log.error("Scheduled task error: {}", throwable.getMessage()));
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setAwaitTerminationSeconds(30);
        return scheduler;
    }
}

Rule of thumb: Set pool size to the number of concurrent scheduled tasks you expect.


Distributed Locking with ShedLock

ShedLock ensures that a scheduled task is executed at most once across all instances. It uses a shared lock store (database, Redis, etc.).

sequenceDiagram
    participant I1 as Instance 1
    participant DB as Lock Store (DB)
    participant I2 as Instance 2

    Note over I1,I2: Cron triggers at 2:00 AM
    I1->>DB: Try to acquire lock "reportGeneration"
    DB-->>I1: Lock acquired ✓
    I2->>DB: Try to acquire lock "reportGeneration"
    DB-->>I2: Lock already held ✗
    I1->>I1: Execute report generation
    Note over I2: Skips execution
    I1->>DB: Release lock (or lock_until expires)

diagram

Only one instance acquires the lock. The others skip the execution entirely.


ShedLock Setup

1. Dependencies

1
2
3
4
5
6
7
8
9
10
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-spring</artifactId>
    <version>5.13.0</version>
</dependency>
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-jdbc-template</artifactId>
    <version>5.13.0</version>
</dependency>

2. Lock Table DDL

1
2
3
4
5
6
7
CREATE TABLE IF NOT EXISTS shedlock (
    name       VARCHAR(64)  NOT NULL,
    lock_until TIMESTAMP    NOT NULL,
    locked_at  TIMESTAMP    NOT NULL,
    locked_by  VARCHAR(255) NOT NULL,
    PRIMARY KEY (name)
);

3. Configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class ShedLockConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
                JdbcTemplateLockProvider.Configuration.builder()
                        .withJdbcTemplate(new JdbcTemplate(dataSource))
                        .usingDbTime()
                        .build()
        );
    }
}

4. Annotate Tasks

1
2
3
4
5
6
7
8
9
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(
        name = "reportGeneration",
        lockAtLeastFor = "5m",     // hold lock at least 5 min (prevent double-execution)
        lockAtMostFor = "30m"      // release lock after 30 min max (safety net)
)
public void generateDailyReport() {
    // only one instance executes this
}

lockAtLeastFor: Prevents the lock from being released too quickly. If the task finishes in 1 second, the lock is still held for 5 minutes — ensuring the other instance doesn’t also execute within the same cron trigger window.

lockAtMostFor: Safety net. If the instance crashes mid-execution, the lock expires after this duration instead of being held forever.


Quartz: When You Need More

@Scheduled is simple and stateless. When you need more, consider Quartz:

Feature@ScheduledQuartz
Setup complexityMinimalModerate (tables, config)
Dynamic schedulesNo (compile-time only)Yes (runtime CRUD)
Job persistenceNoneDatabase-backed
Misfire handlingNone (missed = skipped)Configurable policies
ClusteringManual (ShedLock)Built-in
Job chaining/dependenciesNoneListeners and triggers
Job parametersNoneJobDataMap
Pause/resume individual jobsNoneFull lifecycle control

When to use Quartz:

  • Users configure schedules at runtime (admin panel)
  • Jobs must survive restarts (persisted to DB)
  • Complex misfire policies (retry, fire immediately, etc.)
  • Job dependencies (Job B runs after Job A completes)

When @Scheduled + ShedLock is enough:

  • Fixed schedules known at compile time
  • Simple cron/rate/delay patterns
  • Just need distributed lock protection

@Scheduled vs Quartz vs Spring Cloud Task

Dimension@Scheduled + ShedLockQuartzSpring Cloud Task
DeploymentIn-appIn-appSeparate launcher
PersistenceLock table onlyFull job storeTask execution DB
Scaling modelLock-basedClustered schedulerExternal trigger (K8s cron)
Best forSimple periodic jobsComplex schedulingCloud-native, short-lived tasks

Monitoring Scheduled Tasks

Custom Status Endpoint

Expose last-run times via REST:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@GetMapping("/api/tasks/status")
public ResponseEntity<Map<String, Object>> getStatus() {
    Map<String, Object> status = new HashMap<>();
    status.put("reportGeneration", Map.of(
            "lastRun", reportTask.getLastRunTime() != null
                    ? reportTask.getLastRunTime().toString() : "never",
            "schedule", "cron: 0 0 2 * * * (daily at 2 AM)"
    ));
    status.put("healthCheck", Map.of(
            "lastRun", healthCheckTask.getLastRunTime().toString(),
            "serviceStatus", healthCheckTask.getServiceStatus()
    ));
    return ResponseEntity.ok(status);
}

Actuator Integration

Spring Boot Actuator exposes scheduled task info:

1
2
3
4
5
management:
  endpoints:
    web:
      exposure:
        include: scheduledtasks
1
curl http://localhost:8080/actuator/scheduledtasks

Returns all registered scheduled tasks with their configuration.


Common Problems

ProblemCauseSolution
Tasks not executingMissing @EnableSchedulingAdd to main class or config
Tasks blocking each otherSingle-thread defaultConfigure ThreadPoolTaskScheduler with pool size > 1
Duplicate execution in clusterNo distributed lockAdd ShedLock with @SchedulerLock
Task runs longer than intervalfixedRate + slow taskUse fixedDelay or add lockAtLeastFor
Cron not firingWrong cron formatSpring uses 6-field cron (includes seconds)
Lock never released after crashlockAtMostFor too high or missingAlways set reasonable lockAtMostFor
Missed execution after downtime@Scheduled has no misfire handlingUse Quartz if misfires must be handled
ShedLock table not foundDDL not appliedAdd schema.sql or Flyway migration

Full Working Example

The complete implementation is available on GitHub:

spring-boot-examples/35-scheduling

Clone and run:

1
2
3
git clone https://github.com/AnupamSinha/spring-boot-examples.git
cd spring-boot-examples/35-scheduling
./mvnw spring-boot:run

Check task status:

1
2
3
4
curl http://localhost:8080/api/tasks/status

# Manually trigger a task
curl -X POST http://localhost:8080/api/tasks/trigger/healthcheck

References

This post is licensed under CC BY 4.0 by the author.