Spring Boot offers three main ways to schedule recurring work: @Scheduled for simple fixed-rate or cron-expression tasks, Quartz Scheduler for distributed job coordination, and Spring Batch for large-scale data processing jobs. All three share the same blind spot: when a task silently stops running, nothing in Spring Boot tells you.
This guide covers the failure modes specific to each approach and shows you how to add a single line of heartbeat monitoring to detect silent failures before your users do.
The most common silent failure with @Scheduled isn't an exception — it's thread pool exhaustion. By default, Spring Boot runs all @Scheduled methods on a single-threaded task executor. If one scheduled method blocks — waiting on a slow database query, an unresponsive external API, or a deadlock — every other @Scheduled method queues behind it and never runs.
// application.properties — without this, ALL @Scheduled tasks share one thread
spring.task.scheduling.pool.size=5
// ScheduledTasks.java
@Component
public class ScheduledTasks {
@Scheduled(cron = "0 0 2 * * ?") // 2am daily
public void generateInvoices() {
// If this blocks for >24h (e.g., stuck HTTP call), sendReports() never runs
invoiceService.generateAll();
}
@Scheduled(cron = "0 30 2 * * ?") // 2:30am daily
public void sendReports() {
// Silently queued behind generateInvoices() if that thread is stuck
reportService.sendAll();
}
}
The second common failure is exception swallowing. A runtime exception inside a @Scheduled method cancels future executions of that specific task. The scheduler catches the exception, logs it at ERROR level, and permanently stops scheduling that method. Unless you're watching the logs, the job disappears forever.
// This exception permanently cancels future runs of this @Scheduled method
@Scheduled(fixedRate = 60_000)
public void syncInventory() {
try {
inventoryClient.sync(); // Throws on network error
} catch (Exception e) {
log.error("Sync failed", e);
// DON'T rethrow — Spring won't cancel on caught exceptions
// BUT if you forget the try/catch and the exception propagates, Spring stops scheduling this method
}
}
@Scheduled method logs the error and cancels future runs. In Spring Boot 3.x with virtual threads enabled, the behavior can vary. Either way, exceptions inside scheduled methods need explicit handling — and you still won't know the task stopped unless you're monitoring it externally.
Quartz Scheduler is more robust than @Scheduled, but clustering introduces new silent failure modes. In a clustered Quartz setup, jobs run on whichever node acquires the database lock. If the node that acquired the lock crashes mid-execution, Quartz relies on its misfire policy to recover.
The default misfire threshold is 60 seconds. If your application server was down for 2 minutes — a routine restart during a deploy — Quartz may mark the trigger as "misfired" and either skip the missed execution (MISFIRE_INSTRUCTION_DO_NOTHING) or fire once immediately (MISFIRE_INSTRUCTION_FIRE_ONCE_NOW). The correct behavior depends on your job semantics, but the default isn't always right:
// Quartz job with explicit misfire handling
@Bean
public JobDetail reportJobDetail() {
return JobBuilder.newJob(DailyReportJob.class)
.withIdentity("dailyReport")
.storeDurably()
.build();
}
@Bean
public Trigger reportTrigger() {
CronScheduleBuilder schedule = CronScheduleBuilder
.cronSchedule("0 0 6 * * ?") // 6am daily
.withMisfireHandlingInstructionDoNothing(); // Skip missed runs — right for reports
// vs .withMisfireHandlingInstructionFireAndProceed() — run immediately — right for cleanup
return TriggerBuilder.newTrigger()
.forJob(reportJobDetail())
.withSchedule(schedule)
.build();
}
// The job itself — Quartz won't tell you if this throws
public class DailyReportJob implements Job {
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
// Exception here marks the trigger as errored — future runs continue
// But nobody alerts you unless you add explicit monitoring
reportService.generateDaily();
}
}
Spring Batch records job execution history in the BATCH_JOB_EXECUTION table. A failed step marks the job as FAILED, and Spring Batch will retry it on the next trigger. This sounds robust — until you realize that a job stuck in STARTED state (due to a JVM crash mid-execution) blocks all future runs of the same job until manually restarted.
More commonly: a partitioned job finishes COMPLETED but processed zero records because the data source returned an empty result set due to a query bug or a misconfigured date range. Spring Batch has no concept of "successfully processed the wrong thing." The job reports green; your data is silently wrong.
// Spring Batch job with completion listener — but still no external alert
@Bean
public Job importJob(JobRepository jobRepository, Step importStep) {
return new JobBuilder("importJob", jobRepository)
.listener(new JobExecutionListenerSupport() {
@Override
public void afterJob(JobExecution jobExecution) {
if (jobExecution.getStatus() == BatchStatus.COMPLETED) {
long count = jobExecution.getStepExecutions().stream()
.mapToLong(StepExecution::getWriteCount).sum();
if (count == 0) {
log.warn("Import completed but wrote 0 records — possible data source issue");
// Log warning — but who reads logs at 3am?
}
}
}
})
.start(importStep)
.build();
}
The most reliable way to monitor scheduled tasks externally is the heartbeat pattern: at the end of each successful run, your job pings a monitoring URL. If the ping stops arriving — because the job stopped running, threw an exception before completing, or the whole application died — the monitor alerts you.
@Component
public class ScheduledTasks {
private final RestTemplate restTemplate = new RestTemplate();
private static final String HEARTBEAT_URL = "https://cronwatch.example.com/api/heartbeat/YOUR_TOKEN";
@Scheduled(cron = "0 0 2 * * ?")
public void generateInvoices() {
try {
invoiceService.generateAll();
// Only ping if the job actually completed successfully
restTemplate.getForObject(HEARTBEAT_URL, String.class);
} catch (Exception e) {
log.error("Invoice generation failed", e);
// No ping — monitor will alert when the heartbeat window expires
}
}
}
If you don't want the synchronous HTTP call on the critical path, fire it asynchronously:
@Scheduled(cron = "0 0 2 * * ?")
public void generateInvoices() {
invoiceService.generateAll();
// Non-blocking ping — won't slow down the task
CompletableFuture.runAsync(() -> {
try {
new URL(HEARTBEAT_URL).openConnection().getContent();
} catch (IOException ignored) {}
});
}
public class DailyReportJob implements Job {
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
try {
reportService.generateDaily();
// Ping heartbeat on success
HttpURLConnection conn = (HttpURLConnection)
new URL("https://cronwatch.example.com/api/heartbeat/YOUR_TOKEN").openConnection();
conn.setRequestMethod("GET");
conn.getResponseCode();
conn.disconnect();
} catch (Exception e) {
throw new JobExecutionException(e);
}
}
}
@Bean
public Job importJob(JobRepository jobRepository, Step importStep) {
return new JobBuilder("importJob", jobRepository)
.listener(new JobExecutionListenerSupport() {
@Override
public void afterJob(JobExecution jobExecution) {
if (jobExecution.getStatus() == BatchStatus.COMPLETED) {
// Only ping if completed successfully AND processed records
long writeCount = jobExecution.getStepExecutions().stream()
.mapToLong(StepExecution::getWriteCount).sum();
if (writeCount > 0) {
try {
new URL("https://cronwatch.example.com/api/heartbeat/YOUR_TOKEN")
.openStream().close();
} catch (IOException ignored) {}
}
}
}
})
.start(importStep)
.build();
}
Spring Boot Actuator provides a /actuator/scheduledtasks endpoint that lists all registered scheduled tasks and their cron expressions. It does not tell you whether a task actually ran successfully — only what's registered. Still useful for verifying configuration:
# application.properties
management.endpoints.web.exposure.include=health,info,scheduledtasks
management.endpoint.scheduledtasks.enabled=true
# Response from /actuator/scheduledtasks
{
"cron": [
{
"runnable": { "target": "com.example.ScheduledTasks.generateInvoices" },
"expression": "0 0 2 * * ?"
}
],
"fixedRate": [],
"fixedDelay": []
}
This confirms the task is registered, but if the thread pool is exhausted or the method throws, the endpoint still shows the task as configured. You need external heartbeat monitoring to know it's actually running.
Spring's cron format differs from standard Unix cron in one important way: it has 6 fields (seconds minutes hours day-of-month month day-of-week) instead of 5. 0 0 2 * * ? means "at 2:00:00 AM every day" — not what a Unix cron parser would produce for the same string.
Before adding a cron expression to a @Scheduled or Quartz trigger, validate it in a parser that understands Spring's 6-field format:
Paste any cron expression to get the plain-English description and next 5 run times — works with both Spring 6-field format and standard 5-field Unix cron.
Try the free cron parser →spring.task.scheduling.pool.size > 1 if you have more than one @Scheduled method@Scheduled method — uncaught exceptions cancel future runsThe heartbeat pattern works best when paired with a monitor that fires an alert when the ping stops — a dead-man's-switch. You configure a grace period (e.g., 30 minutes after the expected run time) and get alerted if no ping arrives in that window.
CronWatch is a dead-man's-switch monitor for scheduled jobs. Add one line to your Spring Boot task; get alerted by email if it stops pinging. No infrastructure to run, no agents to install — just a curl-compatible heartbeat URL.
Join the CronWatch waitlist → Free cron parser