Scheduled tasks are one of those things that look simple until you actually need them in production. A nightly database cleanup, an hourly sync with an external API, a daily report generation job—these are common requirements that every non-trivial application has. Spring Boot’s @Scheduled annotation handles the simple cases cleanly. When you need distributed coordination, persistence across restarts, or fine-grained job monitoring, Quartz picks up where @Scheduled leaves off.
This guide covers both: how @Scheduled works and where it breaks down, and how to bring in Quartz when your requirements outgrow the simple annotation.
Enabling Scheduling in Spring Boot
Out of the box, scheduling is disabled. You enable it with a single annotation on a configuration class or your main application class:
@SpringBootApplication
@EnableScheduling
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
That annotation triggers Spring’s scheduling infrastructure. Without it, any @Scheduled methods in your beans are ignored entirely—no warning, no error, they just never run.
The @Scheduled Annotation
Once scheduling is enabled, you annotate any Spring-managed bean method with @Scheduled. The method must have a void return type and take no parameters.
@Component
public class ReportJob {
private static final Logger log = LoggerFactory.getLogger(ReportJob.class);
@Scheduled(cron = "0 0 6 * * *")
public void generateDailyReport() {
log.info("Starting daily report generation");
// report logic here
}
}
Spring wraps the method call and invokes it on the configured schedule. The three main scheduling modes are fixedRate, fixedDelay, and cron.
Fixed Rate vs Fixed Delay
These two options confuse most developers the first time they read about them. The difference is where the interval is measured from.
fixedRate triggers the method every N milliseconds from the start of the previous execution:
@Scheduled(fixedRate = 5000)
public void pollExternalApi() {
// Called at T=0, T=5000, T=10000, T=15000...
// regardless of how long the method takes
}
fixedDelay triggers the method N milliseconds after the completion of the previous execution:
@Scheduled(fixedDelay = 5000)
public void syncInventory() {
// Called at T=0, then 5000ms after it finishes
// If execution takes 3 seconds: T=0, T=8000, T=16000...
}
The right choice depends on what you’re doing:
- Use
fixedRatefor heartbeats, polling, or anything where clock-alignment matters more than execution time. - Use
fixedDelayfor tasks that must complete before the next one starts, or where you want breathing room between runs.
If your fixedRate task takes longer than the interval, Spring queues the next execution rather than overlapping them (by default—more on that in the thread pool section).
You can also add an initial delay to prevent all jobs from firing at startup simultaneously:
@Scheduled(fixedRate = 60000, initialDelay = 30000)
public void warmupAndPoll() {
// Waits 30 seconds before first execution, then runs every 60 seconds
}
Spring Boot Cron Expressions
Spring Boot uses a six-field cron expression format: second minute hour day-of-month month day-of-week. This differs from the standard Unix cron, which has five fields (no seconds).
@Scheduled(cron = "0 30 9 * * MON-FRI")
// │ │ │ │ │ │
// │ │ │ │ │ └── Day of week (MON-FRI)
// │ │ │ │ └───── Month (* = every month)
// │ │ │ └──────── Day of month (* = every day)
// │ │ └────────── Hour (9 AM)
// │ └───────────── Minute (30)
// └──────────────── Second (0)
That expression runs at 9:30 AM on weekdays. Common patterns:
// Every 5 minutes
@Scheduled(cron = "0 */5 * * * *")
// Daily at midnight
@Scheduled(cron = "0 0 0 * * *")
// First day of every month at 8 AM
@Scheduled(cron = "0 0 8 1 * *")
// Every weekday at 6 AM
@Scheduled(cron = "0 0 6 * * MON-FRI")
// Every 15 seconds (useful in development)
@Scheduled(cron = "*/15 * * * * *")
Spring also accepts cron expressions from properties files, which keeps your schedule configurable without redeployment:
@Scheduled(cron = "${app.jobs.report.cron:0 0 6 * * *}")
public void generateDailyReport() {
// Uses app.jobs.report.cron property, falls back to 6 AM daily
}
# application.yml
app:
jobs:
report:
cron: "0 0 7 * * MON-FRI" # Override: weekdays at 7 AM
This pattern is useful when different environments (dev, staging, production) need different schedules.
Configuring the Thread Pool
By default, Spring scheduling uses a single-threaded executor. Every scheduled task runs on the same thread. If one task runs long, it blocks all other tasks from running on time.
Verify this is a problem by looking at the default auto-configuration: ThreadPoolTaskScheduler with poolSize = 1. To fix it, define your own:
@Configuration
public class SchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("scheduled-task-");
scheduler.setErrorHandler(t -> log.error("Scheduled task error", t));
scheduler.initialize();
registrar.setTaskScheduler(scheduler);
}
}
With multiple threads, tasks run concurrently. But by default, multiple invocations of the same @Scheduled method will not overlap—Spring waits for the previous invocation to complete before scheduling the next one (for fixedRate and fixedDelay).
If you explicitly want concurrent execution of the same task (rare, and usually a sign you need Quartz), add @Async alongside @Scheduled and configure an async executor.
You can also configure the pool size through properties without writing a SchedulingConfigurer:
spring:
task:
scheduling:
pool:
size: 10
thread-name-prefix: "sched-"
When @Scheduled Is Not Enough
@Scheduled works well for single-instance deployments. The limitations show up when you scale horizontally:
- Multiple instances run the same job. If you run three instances of your application, all three fire their
@Scheduledtasks independently. A report gets generated three times. An email gets sent three times. - No job persistence. If the application restarts mid-execution, the job is simply lost. There’s no record of what ran, what didn’t, or how far it got.
- No retry logic. A task that throws an exception just logs the error and tries again at the next interval. There’s no backoff, no dead-letter mechanism, no alerting.
- No job history or monitoring. You have no visibility into execution history beyond whatever you log yourself.
If any of these matter to you, Quartz is worth the setup cost.
Adding Quartz to Spring Boot
Spring Boot has first-class Quartz support via spring-boot-starter-quartz:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
For persistent jobs (the main reason to use Quartz over @Scheduled), add your database driver and a connection pool if you haven’t already.
Quartz Core Concepts
Quartz separates job definition from job scheduling. Three objects you need to understand:
Job: The work to be done. Implements theJobinterface and itsexecutemethod.JobDetail: Metadata about the job—its class, name, group, and any data it needs.Trigger: When and how often the job runs.SimpleTriggerfor interval-based,CronTriggerfor cron-based.
// The job itself
@Component
public class InventorySyncJob implements Job {
// Use field injection for Spring beans — constructor injection doesn't work
// because Quartz instantiates this class directly
@Autowired
private InventoryService inventoryService;
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
try {
inventoryService.syncFromWarehouse();
} catch (Exception e) {
throw new JobExecutionException(e);
}
}
}
Register the job and trigger in a configuration class:
@Configuration
public class QuartzConfig {
@Bean
public JobDetail inventorySyncJobDetail() {
return JobBuilder.newJob(InventorySyncJob.class)
.withIdentity("inventorySync", "inventory")
.withDescription("Sync inventory from warehouse system")
.storeDurably() // Keep the job even if no trigger is associated
.build();
}
@Bean
public Trigger inventorySyncTrigger(JobDetail inventorySyncJobDetail) {
return TriggerBuilder.newTrigger()
.forJob(inventorySyncJobDetail)
.withIdentity("inventorySyncTrigger", "inventory")
.withSchedule(
CronScheduleBuilder.cronSchedule("0 0 */2 * * *") // Every 2 hours
)
.build();
}
}
Spring Boot’s auto-configuration detects JobDetail and Trigger beans and registers them with the Scheduler automatically.
Injecting Spring Beans Into Quartz Jobs
Quartz instantiates Job classes itself, bypassing Spring’s dependency injection by default. The standard workaround is Spring’s SpringBeanJobFactory:
@Configuration
public class QuartzConfig {
@Bean
public SpringBeanJobFactory springBeanJobFactory(ApplicationContext applicationContext) {
AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory();
jobFactory.setApplicationContext(applicationContext);
return jobFactory;
}
}
Spring Boot’s spring-boot-starter-quartz configures this automatically. When you use the starter, @Autowired fields in your Job classes work without any additional setup.
Persistent Quartz Scheduler
In-memory scheduling is fine for development. For production, configure Quartz to use a database. This gives you job persistence across restarts and is required for clustered (distributed) scheduling.
spring:
quartz:
job-store-type: jdbc
jdbc:
initialize-schema: always # Use 'never' after first run in production
properties:
org.quartz.scheduler.instanceName: MyScheduler
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: false
org.quartz.threadPool.threadCount: 10
Quartz creates its own set of tables (QRTZ_JOB_DETAILS, QRTZ_TRIGGERS, QRTZ_FIRED_TRIGGERS, etc.) to store job state. With initialize-schema: always, Spring Boot runs the DDL scripts automatically on startup. Switch to never once the schema exists in production to avoid the overhead.
Distributed Scheduling with Quartz Clustering
The real value of Quartz over @Scheduled in a multi-instance deployment is clustering. When clustering is enabled, Quartz uses database locking to ensure only one node fires each trigger at a time.
spring:
quartz:
job-store-type: jdbc
properties:
org.quartz.scheduler.instanceId: AUTO
org.quartz.jobStore.isClustered: true
org.quartz.jobStore.clusterCheckinInterval: 20000 # 20 seconds
Each scheduler instance has a unique instanceId (set to AUTO to generate one from hostname + timestamp). The cluster check-in interval controls how frequently instances heartbeat to the database. If a node fails, another node picks up its triggers after clusterCheckinInterval × 2 milliseconds.
All nodes in the cluster must share the same database and have their clocks synchronized (within a second or two). Use NTP if you’re running on VMs or containers.
Passing Data to Quartz Jobs
Jobs sometimes need runtime parameters—an email address to notify, a date range to process, a batch size. Quartz provides a JobDataMap for this:
@Bean
public JobDetail reportJobDetail() {
JobDataMap data = new JobDataMap();
data.put("reportType", "MONTHLY_SUMMARY");
data.put("recipientEmail", "ops@example.com");
return JobBuilder.newJob(ReportGenerationJob.class)
.withIdentity("monthlyReport", "reports")
.usingJobData(data)
.storeDurably()
.build();
}
In the job, access the data map through the execution context:
public class ReportGenerationJob implements Job {
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
JobDataMap data = context.getMergedJobDataMap();
String reportType = data.getString("reportType");
String recipient = data.getString("recipientEmail");
// generate and send report
}
}
getMergedJobDataMap() merges data from both the JobDetail and the trigger, with trigger data taking precedence. This lets you define a generic job and parameterize it differently per trigger.
Practical Choice: @Scheduled or Quartz?
Use @Scheduled when:
- You’re running a single instance or don’t mind duplicate job execution across instances
- The task is idempotent and running it twice is harmless
- You need something up and running in five minutes
- Job history and retry logic aren’t requirements
Use Quartz when:
- You’re running multiple instances and need guaranteed single-execution semantics
- Jobs must survive application restarts
- You need execution history, retry behavior, or job monitoring
- Tasks require complex dependencies or chains
The migration path isn’t painful: start with @Scheduled, and if you hit the distributed-execution problem, add the Quartz starter, convert your @Scheduled methods to Job classes, and configure the JDBC job store. The business logic doesn’t change—just the scheduling mechanism around it.
Summary
Spring Boot scheduling gives you two solid options for different needs. @Scheduled with @EnableScheduling handles the common case: periodic tasks in a single-instance deployment, configured with cron expressions or interval parameters. Configure a thread pool if you have more than a handful of tasks running at different intervals.
When you outgrow @Scheduled—specifically when horizontal scaling creates duplicate execution problems—Quartz provides database-backed persistence and cluster-aware locking. The spring-boot-starter-quartz dependency and a few configuration properties are all you need to get a persistent, clustered scheduler running on top of your existing Spring Boot application.