Back to Scheduling: @Scheduled, ShedLock, Retry
Q4Question 4 of 4

What are @Scheduled limitations?

Key Concepts
  • By default all @Scheduled methods share one thread, so a slow job blocks every other scheduled job.
  • Fix the single-thread limit with a ThreadPoolTaskScheduler via SchedulingConfigurer or the pool-size property.
  • No persistence means triggers live only in memory; if the app is down at trigger time the run is missed and never caught up.
  • Fix persistence with Quartz JDBCJobStore for durable schedules and misfire handling, or use an external scheduler.
  • No runtime rescheduling means the cron string is fixed at startup and cannot change without a redeploy.
  • Fix dynamic scheduling with a programmatic TaskScheduler and a Trigger that reads the cron from a database at runtime.
  • The escalation ladder is: thread pool for concurrency, Quartz for persistence, and programmatic triggers for dynamic schedules.

Definition

`@Scheduled` is single-threaded by default, has no persistence of triggers, and has no runtime rescheduling -- fix with a thread pool, Quartz, and programmatic dynamic triggers respectively.
  • Single-threaded -- all @Scheduled methods share one thread, so a slow job blocks every other job until it finishes; fix with ThreadPoolTaskScheduler or @Async.
  • No persistence -- triggers live only in the JVM's memory, so if the app is down at trigger time the run is missed with no catch-up and no history; fix with Quartz JDBCJobStore or an external scheduler.
  • No runtime rescheduling -- the cron string is bound at startup and cannot change without a redeploy; fix with programmatic TaskScheduler.schedule() and a dynamic Trigger that reads the cron from a database.
  • Escalation ladder -- @Scheduled is perfect for simple, fixed, best-effort in-process jobs; beyond that you escalate to a thread pool, Quartz, or external scheduling.

@Scheduled is like a single clerk at National Bank of India with one appointment book. Limitation one: that one clerk does one task at a time, so a long task makes everyone else wait. Limitation two: the appointment book is only in the clerk's head, so if the clerk goes home sick the missed appointments are simply forgotten. Limitation three: appointments are written in permanent ink and cannot be changed on the fly. The fixes are to hire more clerks (thread pool), use a shared written diary that survives absence (Quartz), and allow appointment times to be edited during the day (dynamic triggers).

Understanding the Concepts

Why is @Scheduled single-threaded by default?

Spring creates one thread for all @Scheduled tasks. If you have three jobs and one takes an hour, the other two cannot run until it finishes, even though their trigger time arrived. This silently serializes everything.

JAVA
package com.bank.batch;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class BlockingJobs {

    @Scheduled(fixedRate = 60_000)
    public void reconciliation() {
        // Takes 40 minutes -- blocks the single thread
        reconciliationService.run();
    }

    @Scheduled(fixedRate = 30_000)
    public void heartbeat() {
        // Never runs on time because reconciliation holds the thread
        monitorService.sendHeartbeat();
    }

    @Scheduled(fixedRate = 300_000)
    public void cacheRefresh() {
        // Also delayed -- waiting for reconciliation to finish
        cacheService.refresh();
    }
}
TEXT
Expected behavior with single thread:
- 00:00: reconciliation starts, takes 40 min
- 00:01: heartbeat trigger fires -- BLOCKED, waiting
- 00:05: cacheRefresh trigger fires -- BLOCKED, waiting
- 00:40: reconciliation finishes
- 00:40: heartbeat and cacheRefresh finally run (40 min late)

How do you fix the single-thread limitation with a thread pool?

Implement SchedulingConfigurer and register a ThreadPoolTaskScheduler with multiple threads, or set spring.task.scheduling.pool.size in properties.

JAVA
package com.bank.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;

@Configuration
@EnableScheduling
public class SchedulerConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        ThreadPoolTaskScheduler pool = new ThreadPoolTaskScheduler();
        pool.setPoolSize(5);
        pool.setThreadNamePrefix("sched-");
        pool.initialize();
        registrar.setTaskScheduler(pool);
    }
}
TEXT
Expected behavior with 5 threads:
- 00:00: reconciliation starts on sched-1
- 00:01: heartbeat starts on sched-2 (not blocked!)
- 00:05: cacheRefresh starts on sched-3 (not blocked!)
- All three run concurrently

What does no persistence mean for @Scheduled?

Triggers live only in the running JVM's memory. There is no stored job definition, no execution history, and no catch-up. If the application is down at trigger time (deploy, crash, maintenance), that run is simply missed.

JAVA
package com.bank.batch;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class ComplianceReport {

    @Scheduled(cron = "0 0 2 * * *")  // 2 AM daily
    public void generateReport() {
        // If the app is down at 2 AM, this run is MISSED
        // No record, no catch-up, no alert
        reportService.generateDaily();
    }
}
TEXT
Scenario: deployment window from 1:50 AM to 2:15 AM
- 2:00 AM: trigger fires -- app is down
- 2:15 AM: app restarts -- the 2 AM run is lost forever
- Next run: 2:00 AM tomorrow
- Compliance report for today is MISSING

How does Quartz fix the persistence gap?

Quartz with a JDBCJobStore stores jobs and triggers in the database. Schedules survive restarts, and misfire handling decides what to do when a trigger was missed while the app was down.

PROPERTIES
# application.properties
spring.quartz.job-store-type=jdbc
spring.quartz.properties.org.quartz.jobStore.misfireThreshold=60000
# Misfire policy options: fire now, fire once, do nothing
JAVA
package com.bank.batch;

import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.springframework.stereotype.Component;

@Component
public class PersistentComplianceJob implements Job {

    @Override
    public void execute(JobExecutionContext context) {
        // Quartz persists this job in the database
        // If missed at 2 AM, misfire handler runs it on restart
        reportService.generateDaily();
    }
}
TEXT
Scenario with Quartz:
- 2:00 AM: trigger fires -- app is down, trigger stored as misfired
- 2:15 AM: app restarts, Quartz checks for misfires
- 2:15 AM: misfire handler fires the job (fire-now policy)
- Compliance report is generated, just 15 minutes late

Why can you not reschedule @Scheduled at runtime?

The cron string in @Scheduled(cron = "...") is bound at startup. To change it you must redeploy. Business often needs to adjust a job's time without a release.

JAVA
package com.bank.batch;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class FixedScheduleJob {

    // This cron is burned in at startup -- no runtime change
    @Scheduled(cron = "0 0 22 * * *")  // 10 PM
    public void generateStatements() {
        // Business wants to move this to 8 PM at month-end
        // Requires a code change and redeploy!
    }
}
TEXT
Problem:
- Operations wants to move the job to 8 PM during month-end
- Requires a code change, PR, build, deploy
- Takes hours instead of seconds

How do you implement dynamic scheduling with a programmatic Trigger?

Inject a TaskScheduler and schedule a Runnable with a Trigger whose next execution is computed from a cron value read dynamically from a database or config. Changing the stored value reschedules the job live.

JAVA
package com.bank.config;

import com.bank.service.CronConfigService;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.support.CronTrigger;
import jakarta.annotation.PostConstruct;

@Configuration
public class DynamicScheduleConfig {

    private final TaskScheduler taskScheduler;
    private final CronConfigService cronConfig;
    private final Runnable statementJob;

    public DynamicScheduleConfig(TaskScheduler taskScheduler,
                                  CronConfigService cronConfig,
                                  Runnable statementJob) {
        this.taskScheduler = taskScheduler;
        this.cronConfig = cronConfig;
        this.statementJob = statementJob;
    }

    @PostConstruct
    public void schedule() {
        taskScheduler.schedule(statementJob, triggerContext -> {
            // Re-reads cron from DB on every computation
            String cron = cronConfig.getCron("statement-job");
            return new CronTrigger(cron).nextExecution(triggerContext);
        });
    }
}
JAVA
package com.bank.service;

import com.bank.repository.CronConfigRepository;
import org.springframework.stereotype.Service;

@Service
public class CronConfigService {

    private final CronConfigRepository repo;

    public CronConfigService(CronConfigRepository repo) {
        this.repo = repo;
    }

    public String getCron(String jobName) {
        return repo.findByJobName(jobName)
                .map(c -> c.getCronExpression())
                .orElse("0 0 22 * * *"); // default 10 PM
    }
}
TEXT
Expected behavior:
- Job initially runs at 10 PM (default cron)
- DBA updates cron_config table: statement-job = '0 0 20 * * *'
- Next trigger computation reads new cron
- Job now runs at 8 PM -- no redeploy needed

How do you offload heavy work from the scheduler thread with @Async?

Keep the @Scheduled method quick and offload heavy work to an @Async method on a separate executor, so the scheduler thread is freed immediately.

JAVA
package com.bank.batch;

import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class AsyncScheduledJob {

    private final HeavyWorkService heavyWork;

    public AsyncScheduledJob(HeavyWorkService heavyWork) {
        this.heavyWork = heavyWork;
    }

    @Scheduled(fixedRate = 60_000)
    public void trigger() {
        // Quick -- just dispatches the work
        heavyWork.reconcile();
    }
}

@Component
class HeavyWorkService {

    @Async("batchExecutor")
    public void reconcile() {
        // Runs on a separate thread pool -- scheduler thread freed
        // Takes 40 minutes but does not block the scheduler
    }
}
TEXT
Expected behavior:
- Scheduler thread fires trigger() in milliseconds
- reconcile() runs on batchExecutor thread pool
- Scheduler thread immediately available for other jobs

What is the escalation ladder for scheduling in Spring?

The @Scheduled annotation is perfect for simple, fixed, best-effort in-process jobs. The moment you need more, you escalate step by step.

TEXT
Level 1: @Scheduled (simple, fixed, best-effort)
  Problem: single thread blocks others
  Fix: ThreadPoolTaskScheduler (pool.size = N)

Level 2: ThreadPoolTaskScheduler (concurrent, still in-memory)
  Problem: missed runs when app is down, no history
  Fix: Quartz JDBCJobStore or Kubernetes CronJob

Level 3: Quartz or external scheduler (persistent, misfire handling)
  Problem: schedule fixed at deploy time
  Fix: Programmatic TaskScheduler + dynamic Trigger

Level 4: Dynamic scheduling (runtime reschedule)
  Problem: runs on every instance
  Fix: ShedLock / Quartz clustering / leader election
JAVA
// Level 1: Simple @Scheduled -- good for cache refresh
@Scheduled(fixedRate = 300_000)
public void refreshCache() { cacheService.refresh(); }

// Level 3: Quartz -- good for must-not-miss compliance reports
// Level 4: Dynamic + ShedLock -- good for adjustable production jobs
TEXT
Decision guide:
- Fixed, best-effort, single instance? --> @Scheduled
- Need concurrency? --> Add thread pool
- Must not miss runs? --> Quartz or external scheduler
- Need runtime schedule changes? --> Programmatic dynamic trigger
- Multiple instances? --> ShedLock / Quartz clustering

Visual Overview

DIAGRAM
  @Scheduled Escalation Ladder

  ┌──────────────────────────────────────────────┐
  │  Level 4: Dynamic + Distributed               │
  │  Programmatic TaskScheduler + ShedLock         │
  │  Runtime reschedule + run-once in cluster      │
  ├──────────────────────────────────────────────┤
  │  Level 3: Persistent                           │
  │  Quartz JDBCJobStore / Kubernetes CronJob       │
  │  Misfire handling + execution history          │
  ├──────────────────────────────────────────────┤
  │  Level 2: Concurrent                           │
  │  ThreadPoolTaskScheduler (pool.size = N)       │
  │  Multiple jobs run in parallel                 │
  ├──────────────────────────────────────────────┤
  │  Level 1: Basic @Scheduled                     │
  │  Single thread, in-memory, fixed cron          │
  │  Perfect for simple best-effort jobs           │
  └──────────────────────────────────────────────┘
DIAGRAM
  Single-threaded problem:

  Thread ──┬── reconciliation (40 min) ──┬── heartbeat ── cache
           │                             │
         00:00                         00:40
                                     heartbeat 40 min late!

  Thread-pool fix:

  sched-1 ── reconciliation (40 min) ──
  sched-2 ── heartbeat ── heartbeat ── heartbeat ──
  sched-3 ── cache ─────── cache ──────
                 (all run on time)

Key Takeaway

Spring's @Scheduled is a simple in-memory scheduler with three limitations. First, it is single-threaded by default, so all scheduled methods share one thread and a slow job blocks every other job; fix this by registering a ThreadPoolTaskScheduler so independent jobs run concurrently. Second, it has no persistence: triggers live only in the running JVM, so if the app is down at trigger time the run is missed with no catch-up; fix this by moving must-not-miss jobs to Quartz with a JDBC job store or an external scheduler like a Kubernetes CronJob. Third, it has no runtime rescheduling: the cron string is bound at startup; fix this by scheduling programmatically with a dynamic Trigger that reads the cron from a database.

At National Bank of India, all three limitations caused production issues. A forty-minute reconciliation job on the single default thread starved the heartbeat and cache-refresh jobs. A compliance extract scheduled for 2 AM was silently missed during a deployment window. And operations needed to move the statement run to 8 PM at month-end without a redeploy. The thread pool, Quartz persistence, and dynamic cron trigger solved each one.

The way Pankaj frames it is as an escalation ladder. @Scheduled is perfect for fixed, in-process, best-effort periodic work. Add a thread pool for concurrency. Move to Quartz or an external scheduler for persistence and misfire handling. Use a programmatic dynamic trigger for runtime rescheduling. And combine with ShedLock or Quartz clustering for run-once-in-a-cluster semantics.

Remember one sentence: @Scheduled is single-threaded, in-memory, and fixed at startup -- escalate to a thread pool for concurrency, Quartz for persistence, and a dynamic trigger for runtime rescheduling.

---

Share
Use ← → arrow keys to navigate