← Blog  ·  October 2026

Spring Boot Scheduled Task Monitoring: Alerts When @Scheduled Jobs Stop Running

Your nightly invoice generation job quietly stopped three weeks ago. The @Scheduled method is still annotated, the application logs show no exceptions, Spring Boot Actuator reports the app as healthy. You found out when accounting asked why invoices weren't going out.

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.


Failure modes by scheduler

@Scheduled: thread pool starvation

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
    }
}
⚠️ Spring Boot 2.x vs 3.x behavior differs In Spring Boot 2.x, an uncaught exception in a @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: misfire policies and clustered state drift

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: partitioned step failures and job repository gaps

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();
}

Adding heartbeat monitoring in 5 lines

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.

For @Scheduled tasks

@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) {}
    });
}

For Quartz jobs

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);
        }
    }
}

For Spring Batch jobs

@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();
}

Using Spring Boot Actuator for internal visibility

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.


Testing your cron expressions

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:

🕐 Validate your Spring cron expression

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 →

Production checklist: Spring Boot cron job monitoring


Dead-man's-switch monitoring for Spring Boot

The 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.

🔔 Never miss a failed Spring Boot scheduled task again

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