Hire Me
← All Writing Spring Boot

Distributed Cron Jobs with ShedLock and Spring Scheduling

How to prevent duplicate execution of scheduled tasks across multiple Spring Boot instances using ShedLock — covering setup, lock providers, lock duration tuning, and observability.

@Scheduled works perfectly for a single-instance Spring Boot application. The moment you scale to two instances, every scheduled task runs twice — once on each node. For a job that sends emails, places orders, or generates reports, this is a serious bug. ShedLock solves it by acquiring a distributed lock before executing the task, ensuring only one node runs it at a time.

The problem with @Scheduled at scale

@Scheduled(cron = "0 0 8 * * MON-FRI")   // runs on every instance
public void sendDailyReport() {
    reportService.generate();   // generates and sends the report twice
}

With two pods, this sends the report twice. With auto-scaling, it could send it ten times.

How ShedLock works

ShedLock creates a shedlock table (or equivalent) in your datastore. Before executing a task, it inserts or updates a lock row with:

If a second instance tries to acquire the same lock before it expires, it finds the row already claimed and skips execution. After the lock-until time, it becomes available again.

Dependencies

<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-spring</artifactId>
    <version>5.13.0</version>
</dependency>

<!-- Choose your lock provider -->
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-jdbc-template</artifactId>
    <version>5.13.0</version>
</dependency>

Database table

CREATE TABLE shedlock (
    name       VARCHAR(64)  NOT NULL,
    lock_until TIMESTAMP(3) NOT NULL,
    locked_at  TIMESTAMP(3) NOT NULL,
    locked_by  VARCHAR(255) NOT NULL,
    PRIMARY KEY (name)
);

Spring configuration

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulerConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(new JdbcTemplate(dataSource))
                .usingDbTime()   // use DB clock, not application server clock
                .build()
        );
    }
}

usingDbTime() is important for multi-region deployments — application server clocks may drift, causing split-brain lock behaviour. The database clock is the single source of truth.

defaultLockAtMostFor sets the maximum lock duration globally. If a task takes longer than this, the lock is forcibly released — useful recovery from crashed or hung instances.

Annotating tasks

@Component
public class ReportScheduler {

    @Scheduled(cron = "0 0 8 * * MON-FRI")
    @SchedulerLock(
        name                = "sendDailyReport",
        lockAtMostFor       = "5m",    // release if task takes longer than 5min
        lockAtLeastFor      = "4m"     // hold lock for at least 4min after completion
    )
    public void sendDailyReport() {
        reportService.generate();
    }
}

lockAtMostFor: Maximum time to hold the lock. Set to slightly longer than the expected task duration. If the node crashes mid-execution, other nodes can take over after this duration.

lockAtLeastFor: Minimum time to hold the lock after the task completes. Prevents a second node from running the same task seconds after the first finished — useful for tasks that should only run once per window, even on fast completion.

Rule of thumb: lockAtLeastFor ≈ cron interval × 0.9, lockAtMostFor ≈ expected duration × 1.5.

Redis lock provider

For services without a relational database, use Redis:

<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-redis-spring</artifactId>
    <version>5.13.0</version>
</dependency>
@Bean
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
    return new RedisLockProvider(connectionFactory, "production");
}

The environment name ("production") prefixes lock keys — useful if you share a Redis instance across environments.

Observability

ShedLock logs lock acquisition and release at DEBUG level. Promote to INFO for production:

logging:
  level:
    net.javacrumbs.shedlock: INFO

For Micrometer metrics, wrap the task:

@Scheduled(cron = "0 0 8 * * MON-FRI")
@SchedulerLock(name = "sendDailyReport", lockAtMostFor = "5m", lockAtLeastFor = "4m")
public void sendDailyReport() {
    Timer.Sample sample = Timer.start(meterRegistry);
    try {
        reportService.generate();
    } finally {
        sample.stop(meterRegistry.timer("scheduler.task",
            "name", "sendDailyReport", "status", "completed"));
    }
}

Testing locked tasks

Test the task logic independently of the lock. Extract the task body to a service:

// In your test
@Test
void generateReport_sendsEmailOnSuccess() {
    reportService.generate();   // test the logic, not the scheduling
    verify(emailClient).send(any(ReportEmail.class));
}

For integration tests verifying lock behaviour, use SimpleLockProvider in-memory:

@TestConfiguration
public class TestSchedulerConfig {
    @Bean
    @Primary
    public LockProvider lockProvider() {
        return new SimpleLockProvider();
    }
}

Common mistakes

Cron interval shorter than lockAtMostFor: If lockAtMostFor is 10 minutes but the cron fires every 5 minutes, the second invocation will always skip because the lock from the first invocation hasn’t expired. Keep lockAtMostFor well below the cron interval.

Clock skew without usingDbTime: Two nodes with 30-second clock drift can both acquire the lock if the lock-until comparison uses application server time. Always use usingDbTime().

No monitoring of skipped locks: A task that is always skipped (because the lock is always held) produces no errors. Monitor the shedlock table — if locked_at is always from the same node, that node may be hung.

With ShedLock in place, horizontal scaling adds no concurrency risk to your scheduled jobs — each task runs exactly once per scheduled window, regardless of how many instances are running.

If you’re working on distributed Spring Boot services and want a review of scheduling and coordination patterns, get in touch.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.