Side by side
| Feature | @Scheduled | Quartz |
|---|---|---|
| Setup | Annotation, zero deps | Library + (optionally) DB schema |
| Cron syntax | 6-field (with seconds) | 6-7 fields + L, W, # operators |
| Persistence | None (in-memory) | JDBC store (jobs survive restarts) |
| Clustering | None (runs on every node) | Yes (only one node fires each trigger) |
| Dynamic schedules | Limited (recompile or SpEL) | Yes (runtime API) |
| Misfire handling | None | Configurable policies |
| Best for | Single-instance apps, cron-style | Multi-instance apps, complex schedules |
@Scheduled basics
@Configuration
@EnableScheduling
public class AppConfig { }
@Service
public class ReportService {
@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "America/New_York")
public void sendDailyReport() {
// ...
}
}
That's it. Spring creates a single-threaded scheduler by default; configure a pool if you have many concurrent jobs.
Quartz basics
JobDetail job = JobBuilder.newJob(ReportJob.class)
.withIdentity("dailyReport", "reports")
.build();
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("dailyReportTrigger")
.withSchedule(CronScheduleBuilder
.cronSchedule("0 0 9 ? * MON-FRI")
.inTimeZone(TimeZone.getTimeZone("America/New_York")))
.build();
scheduler.scheduleJob(job, trigger);
More boilerplate, but you can store jobs in a database and define triggers at runtime instead of compile time.
The clustering problem
If you deploy a Spring app with @Scheduled to three Kubernetes pods, the cron job runs three times — once per pod. That's usually wrong.
Quartz with a JDBC job store can be configured to run each trigger on exactly one node, using database row-locking as the coordination mechanism. This is the killer feature.
Without Quartz, alternatives include:
- ShedLock — adds locking to
@Scheduledwithout a full Quartz install - Leader election (Spring Cloud) — only the elected leader runs scheduled jobs
- Designate one pod as "the scheduler" via deployment topology
Persistence
With @Scheduled, jobs live in memory. Restart the app: jobs are reconstituted from annotations, but any dynamically created schedules are lost.
Quartz with a JDBC store survives restarts. Schedules created via API persist across deploys. This matters for user-facing scheduling features ("notify me at 9 AM next Tuesday") that you'd otherwise have to manage in your own tables.
Cron syntax differences
Spring @Scheduled cron syntax: 6 fields starting with seconds. Day-of-week 1=Mon (Linux-style, with 0/7=Sunday).
Quartz cron syntax: 6-7 fields. Day-of-week 1=Sunday (offset by one from Linux). Supports L (last), W (nearest weekday), # (nth weekday).
So 0 0 9 ? * MON-FRI works in both (using named days). But 0 0 9 ? * 2-6 means Mon-Fri in Quartz, Tue-Sat in Spring. Always use named days for portability.
When to use which
| If you have… | Use… |
|---|---|
| Single-instance app, fixed schedule | @Scheduled |
| Single instance, complex schedule (L/W/#) | Quartz (without JDBC) |
| Multi-instance app, fixed schedule | @Scheduled + ShedLock |
| Multi-instance app, dynamic schedules | Quartz with JDBC |
| User-facing scheduling features | Quartz with JDBC |