Distributed Task Scheduling in Java: Quartz, ShedLock, and Redisson
You deploy three instances of your service. At 3am the nightly report job fires, and three identical reports land in the same inbox. Nothing is broken, exactly — every instance did precisely what it was told. The problem is that you only wanted one of them to do it.
This is the point at which single-JVM scheduling stops being enough. Below are the three approaches most Java teams end up choosing between — Quartz with clustering, ShedLock, and Redisson — what each actually does, and where each stops.
Why a scheduled job fires on every instance
Spring's @Scheduled, a plain ScheduledExecutorService, and Quartz with its default in-memory job store all share one property: the schedule lives inside the JVM that owns it. There is a timer thread, a set of triggers, and no awareness whatsoever that another process exists. Run four copies of the application and you have four independent schedulers that happen to hold identical instructions.
Every fix for this works by introducing state that all the nodes can see, and they split into two shapes. Either every node keeps scheduling as it does now and they coordinate so that exactly one wins the right to run — that is the lock-based approach — or scheduling itself moves out of the JVM into shared infrastructure, and nodes become workers that consume from it.
The distinction matters more than it first appears, because it decides whether you get deduplication or genuine distribution.
Option 1: Quartz with a clustered JobStore
Quartz is the incumbent, and clustering has been built into it for a long time. The key constraint is that it only works with the JDBC job stores — JobStoreTX or JobStoreCMT. The in-memory RAMJobStore cannot cluster, which catches out teams who set isClustered and assume that is sufficient.
Every node points at the same database and the same set of QRTZ_ tables. When a trigger comes due, whichever node acquires the row lock first is the node that runs it, and Quartz guarantees exactly one firing per scheduled time. It will not reliably be the same node twice, which is the intended behaviour: that is the load balancing.
org.quartz.scheduler.instanceName = ReportScheduler
org.quartz.scheduler.instanceId = AUTO
org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX
org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.StdJDBCDelegate
org.quartz.jobStore.tablePrefix = QRTZ_
org.quartz.jobStore.isClustered = true
org.quartz.jobStore.clusterCheckinInterval = 20000
Every node must carry the same instanceName to be treated as one logical cluster, and each must have a distinct instanceId — setting it to AUTO handles that without needing per-node config files. On Spring Boot the equivalent switch is spring.quartz.job-store-type: jdbc.
Three things are worth knowing before you commit. The first is the schema: roughly a dozen tables to install, version, and carry forward with your application. If you use spring.quartz.jdbc.initialize-schema, set it to never in production and run the official scripts by hand — the always setting recreates the tables on every restart and takes your scheduled state with it.
The second is time. Quartz's own documentation is unusually blunt about this: do not cluster across machines unless their clocks are kept synchronized to within a second of each other by something like NTP. The locking depends on it, and clock drift produces missed or duplicated firings that are extremely unpleasant to diagnose after the fact.
The third is failover, which is opt-in. When a node dies mid-job, only jobs whose JobDetail has the requests recovery flag set are re-executed elsewhere; the flag defaults to false, so by default the work is simply released and waits for the next trigger. Quartz also notes that its cluster-wide lock degrades as nodes are added, typically beyond about three, depending on the database.
Quartz suits you if you are already running it, already have the relational database, and want its mature handling of misfires, calendars, and persistent job state.
Option 2: ShedLock
ShedLock takes a deliberately smaller bite. It is not a scheduler and does not try to be one — it is a distributed lock wrapped around the scheduler you already have. Your @Scheduled methods stay exactly where they are.
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulerConfig {
@Bean
public LockProvider lockProvider(RedisConnectionFactory factory) {
return new RedisLockProvider(factory, "reports");
}
}
Then one annotation on the job itself:
@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "nightlyReport", lockAtMostFor = "15m", lockAtLeastFor = "5m")
public void nightlyReport() {
LockAssert.assertLocked();
reportService.build();
}
Only annotated methods are locked, and the name must be unique — two tasks sharing a name will block each other. LockAssert.assertLocked() is worth including: it fails fast if the wiring is wrong, rather than letting the job run unlocked on every node while looking healthy. Lock providers exist for JDBC, Redis, MongoDB, Hazelcast, ZooKeeper, Cassandra, Couchbase, and Elasticsearch, so it usually fits whatever you already run.
The two timing parameters do different jobs and both matter. lockAtLeastFor holds the lock for a minimum period, which protects very short tasks from running twice when node clocks disagree slightly. lockAtMostFor is a safety net for a node dying mid-task: the lock expires so the job is not blocked forever.
That second parameter deserves care, because it is not a timeout. Nothing interrupts a task that overruns it. If the work takes longer than lockAtMostFor, the lock is simply released while the task is still going, another node can pick it up, and you get exactly the concurrent execution you installed the library to prevent. Set it comfortably above the worst realistic runtime, or use KeepAliveLockProvider, which extends the lock as the task runs.
The honest limitation is in the name. ShedLock deduplicates; it does not distribute. With three nodes, one does the work and two discover the lock is taken and skip. And it does not fail over mid-run — if the node holding the lock dies halfway through, the work is not handed to a survivor; it simply waits for the next scheduled time.
ShedLock suits you if you already have @Scheduled jobs that work, and you want the smallest possible change that stops them running N times.
Option 3: Redisson RScheduledExecutorService
Redisson takes the other shape. Rather than locking around a local scheduler, RScheduledExecutorService moves both the schedule and the execution into Valkey or Redis. It implements the JDK's ScheduledExecutorService, so the interface is the one described on the Java ExecutorService page — the pool just happens to span machines.
RedissonClient redisson = Redisson.create(config);
RScheduledExecutorService scheduler = redisson.getExecutorService("maintenance");
// CleanupTask implements Runnable
scheduler.schedule(new CleanupTask(), CronSchedule.dailyAtHourAndMinute(3, 0));
Cron expressions are supported in the Quartz format through CronSchedule.of("0 0 3 * * ?"), alongside helpers such as dailyAtHourAndMinute and weeklyOnDayAndHourAndMinute.
Tasks are serialized with the configured codec and placed in a queue. Workers poll that queue in submission order, and you register them explicitly:
scheduler.registerWorkers(WorkerOptions.defaults().workers(4));
Because the task object travels to whichever node picks it up, it needs a way back to your data. The @RInject annotation supplies the RedissonClient once the task lands on a worker:
public class CleanupTask implements Runnable {
@RInject
private RedissonClient redisson;
@Override
public void run() {
RMap<String, Session> sessions = redisson.getMap("sessions");
// ...
}
}
Three things follow from this model that the lock-based approach cannot offer. Work is genuinely distributed rather than deduplicated — submit two hundred tasks and they spread across every registered worker instead of one node doing all of them. Each task carries a globally unique 128-bit id, so cancelTask(taskId) cancels it cluster-wide rather than only in the JVM that submitted it. And workers need not be your application instances at all: RedissonNode runs them as standalone processes, which lets you scale scheduled work separately from request-serving capacity.
Recovery is handled by taskRetryInterval, set on ExecutorOptions rather than WorkerOptions and defaulting to five minutes. If a task has not reported completion within that window it is executed again, which is what lets work survive a worker dying. It carries the same warning as ShedLock's lockAtMostFor, and for the same reason: set the interval shorter than the task actually takes and the task will be re-run while the first copy is still going. Size it above the worst realistic runtime, or set it to 0 to switch retries off.
The costs are equally concrete. You take a dependency on Valkey or Redis, your tasks must be serializable because they cross a network, and workers have to be registered rather than simply existing. Full details are in the distributed services documentation.
Which one to choose
| Quartz clustered | ShedLock | Redisson | |
|---|---|---|---|
| What it is | Scheduler with clustering | Lock around your scheduler | Distributed executor and scheduler |
| Backing store | RDBMS only | JDBC, Redis, Mongo, others | Valkey or Redis |
| Spreads work across nodes | Yes | No | Yes |
| Recovers a task mid-run | Opt-in per job — requestsRecovery, off by default | No | Yes, via taskRetryInterval |
| Cron | Yes | From your scheduler | Quartz-format CronSchedule |
| Cost to adopt | Schema plus config | Annotations plus lock provider | Client plus workers |
If your jobs are few, short, and already written as @Scheduled methods, ShedLock is very likely the right answer, and it would be dishonest to suggest otherwise. It is a couple of annotations and a lock provider you probably already have infrastructure for. Its ceiling is real but you may never reach it.
If you are already invested in Quartz and a relational database, clustered Quartz is the path of least resistance, provided you can guarantee synchronized clocks across your nodes.
Redisson earns its place when the work itself needs distributing rather than merely deduplicating — many tasks that should share out across workers, or scheduled capacity you want to scale independently of your web tier. Recovery from a dead worker is on by default here rather than opt-in, though clustered Quartz will do it too once the flag is set. If you are already using Valkey or Redis from Java, it also avoids adding a relational schema purely to hold a schedule.
Frequently asked questions
Why does my scheduled task run on every instance?
Because each instance runs its own scheduler with its own in-memory trigger state and no knowledge of the others. @Scheduled, ScheduledExecutorService, and Quartz with RAMJobStore are all single-JVM by design. Fixing it requires state that every node can see — a shared lock, a shared job store, or a shared task queue.
Can Quartz cluster without a database?
No. Quartz clustering works only with the JDBC job stores, JobStoreTX and JobStoreCMT. RAMJobStore holds triggers in memory and cannot coordinate across processes, so setting isClustered alongside it does not give you a cluster.
Does ShedLock distribute work across nodes?
No, and it does not claim to. It guarantees that only one node executes a given task at a time; the remaining nodes find the lock held and skip that run. If you need the work itself spread across machines, you need a distributed executor rather than a lock.
What is the difference between Quartz clustering and a distributed lock?
Quartz clustering makes the schedule itself shared — triggers live in the database and any node may fire them. A distributed lock leaves each node's schedule alone and arbitrates at execution time. The practical consequence is failover: a clustered scheduler can recover work from a node that dies, though in Quartz that is opt-in per job and off by default, whereas a lock simply expires and waits for the next scheduled run.