# Spring @Scheduled Cron Jobs: Syntax, Thread Pools, and Monitoring

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/spring-scheduled-monitoring

> How Spring @Scheduled cron expressions work, why tasks wait on one scheduler thread or never run, how to run once across replicas, and how to alert on misses.

**To run a Spring method on a cron schedule, put `@EnableScheduling` on a configuration class and annotate a no-argument method of a Spring bean with `@Scheduled(cron = "0 0 2 * * *")`.** Spring's cron has six fields with seconds first, so that expression means 02:00:00 every day, in the JVM's default time zone unless you set `zone`. In Spring Boot, every `@Scheduled` method shares one scheduler thread by default, so one slow task delays the rest, and every replica of the app runs every schedule. A task that throws is logged and runs again at its next occurrence, and a task that never runs leaves no trace at all.

Behavior below is checked against Spring Framework 7.0.10 and Spring Boot 4.1.2, the latest stable releases on October 25, 2026, using the [Spring Framework scheduling reference](https://docs.spring.io/spring-framework/reference/7.0/integration/scheduling.html) and source. The Boot properties shown also exist in Spring Boot 3.5.

## Enable Scheduling and Write a Six-Field Cron Expression

```java
// BillingApplication.java
package com.example.billing;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class BillingApplication {

    public static void main(String[] args) {
        SpringApplication.run(BillingApplication.class, args);
    }
}
```

```java
// jobs/MaintenanceJobs.java
package com.example.billing.jobs;

import java.util.concurrent.TimeUnit;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class MaintenanceJobs {

    private static final Logger log = LoggerFactory.getLogger(MaintenanceJobs.class);

    @Scheduled(cron = "0 0 2 * * *", zone = "UTC") // 02:00:00 UTC every day
    public void rebuildSearchIndex() {
        log.info("Rebuilding the search index");
    }

    @Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES) // every 5 minutes, start to start
    public void refreshExchangeRates() {
        log.info("Refreshing exchange rates");
    }

    @Scheduled(fixedDelay = 30, initialDelay = 10, timeUnit = TimeUnit.SECONDS) // 30 s after each run ends
    public void drainOutbox() {
        log.info("Draining the outbox");
    }
}
```

[`CronExpression`](https://docs.spring.io/spring-framework/docs/7.0.10/javadoc-api/org/springframework/scheduling/support/CronExpression.html) reads second, minute, hour, day of month, month, and day of week, where day of week is 0-7 (0 and 7 are Sunday) or `MON`-`SUN`. Anything but six fields stops the application at startup with "Cron expression must consist of 6 fields" ([CronExpression.java](https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-context/src/main/java/org/springframework/scheduling/support/CronExpression.java#L176-L185)), so a five-field line copied from a crontab fails loudly. Beyond `*`, `,`, `-`, and `/`, Spring accepts:

| Syntax                                                | Field            | Meaning                                                | Example                      |
| ----------------------------------------------------- | ---------------- | ------------------------------------------------------ | ---------------------------- |
| `?`                                                   | Either day       | Same as `*`                                            | `0 0 0 25 12 ?`              |
| `L`, `L-n`                                            | Day of month     | Last day; nth-to-last day                              | `0 0 0 L * *`                |
| `nW`, `LW`                                            | Day of month     | Weekday nearest day n; last weekday                    | `0 0 0 1W * *`               |
| `dL`                                                  | Day of week      | Last day d of the month                                | `0 0 0 * * 5L` (last Friday) |
| `d#n`                                                 | Day of week      | nth day d of the month                                 | `0 0 0 ? * MON#1`            |
| `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly` | Whole expression | Six-field equivalents; `@annually` and `@midnight` too | `@daily` is `0 0 0 * * *`    |

When both day fields are restricted, a date must match both: Spring's tests expect `0 0 0 13 * FRI` to fire only on Friday the 13th ([CronExpressionTests.java](https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java#L495-L510)), where Linux cron runs on every 13th and every Friday. [Cron expression syntax](https://cronradar.com/blog/cron-expression) covers the five-field format and how other schedulers differ.

The other attributes ([`@Scheduled`](https://docs.spring.io/spring-framework/docs/7.0.10/javadoc-api/org/springframework/scheduling/annotation/Scheduled.html)):

- **`zone`** takes an ID such as `America/New_York`. Without it, the scheduler's clock decides, which is the JVM default zone ([ThreadPoolTaskScheduler.java](https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-context/src/main/java/org/springframework/scheduling/concurrent/ThreadPoolTaskScheduler.java#L93)).
- **`fixedRate`** measures start to start and **`fixedDelay`** from the end of one run to the start of the next. **`timeUnit`** (default milliseconds) applies to them and to `initialDelay`, which `cron` doesn't allow ([ScheduledAnnotationBeanPostProcessor.java](https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java#L426-L446)).

### Externalize or Disable a Schedule

`cron`, `zone`, and the `fixedRateString`, `fixedDelayString`, and `initialDelayString` variants resolve property references, as in `@Scheduled(cron = "${jobs.reindex.cron:0 0 2 * * *}")`. The value `-` disables a cron trigger ([`Scheduled.CRON_DISABLED`](https://docs.spring.io/spring-framework/docs/7.0.10/javadoc-api/org/springframework/scheduling/annotation/Scheduled.html#CRON_DISABLED)), so `JOBS_REINDEX_CRON=-` in one environment turns that job off without a code change, and without a word in the logs.

## One Scheduler Thread Runs Every Task by Default

With `@EnableScheduling` present, Spring Boot auto-configures a `ThreadPoolTaskScheduler` whose pool has one thread: `spring.task.scheduling.pool.size` defaults to 1, and the thread is named `scheduling-1` ([Boot reference](https://docs.spring.io/spring-boot/4.1/reference/features/task-execution-and-scheduling.html), [TaskSchedulingProperties.java](https://github.com/spring-projects/spring-boot/blob/v4.1.2/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingProperties.java#L59)). Spring without Boot, finding no scheduler bean, falls back to a single-thread executor as well ([TaskSchedulerRouter.java](https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-context/src/main/java/org/springframework/scheduling/config/TaskSchedulerRouter.java#L219-L224)).

Every task queues for that thread. If the 02:00 report runs for 40 minutes, an every-minute sync due at 02:01 runs once at 02:40, and the occurrences in between never happen, because a cron trigger computes its next time from when the previous run finished ([CronTrigger.java](https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-context/src/main/java/org/springframework/scheduling/support/CronTrigger.java#L109-L143)). Nothing logs the delay.

| Setup                                      | Different tasks run in parallel | A task can overlap itself |
| ------------------------------------------ | ------------------------------- | ------------------------- |
| Default, one thread                        | No                              | No                        |
| `spring.task.scheduling.pool.size=4`       | Up to 4                         | No                        |
| `spring.threads.virtual.enabled=true`      | Yes, except fixed-delay tasks   | Cron and fixed-rate tasks |
| `@EnableAsync` plus `@Async` on the method | Yes, on the async executor      | Yes                       |

- **A bigger pool** still makes each task wait for its own previous run; fixed-rate runs that fall behind "may start late, but will not concurrently execute" ([`ScheduledExecutorService.scheduleAtFixedRate`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ScheduledExecutorService.html)).
- **Virtual threads** (Java 21+) switch Boot to a `SimpleAsyncTaskScheduler`, which [ignores pool settings](https://docs.spring.io/spring-boot/4.1/reference/features/task-execution-and-scheduling.html) and starts each run on a new thread, except fixed-delay tasks, which all share its one scheduler thread ([SimpleAsyncTaskScheduler](https://docs.spring.io/spring-framework/docs/7.0.10/javadoc-api/org/springframework/scheduling/concurrent/SimpleAsyncTaskScheduler.html)). The trigger sees only the hand-off, so a slow run can overlap its next one. `spring.task.scheduling.simple.concurrency-limit` caps concurrent runs.
- **`@Async`** returns as soon as the call is handed to the async executor, and the scheduler counts the run as finished when the call returns ([ReschedulingRunnable.java](https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-context/src/main/java/org/springframework/scheduling/concurrent/ReschedulingRunnable.java#L91-L103)). The same overlap applies, and Spring's task status and timings below then measure the hand-off, not the work.

`@Scheduled(scheduler = "reportScheduler")` (Spring 6.1+) isolates a slow task on a scheduler bean of its own. Any `TaskScheduler` bean you declare switches off Boot's ([TaskSchedulingConfigurations.java](https://github.com/spring-projects/spring-boot/blob/v4.1.2/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingConfigurations.java#L46-L47)), so also declare one named `taskScheduler` for the rest.

## Why a @Scheduled Method Doesn't Run

Some mistakes stop the application at startup with an `IllegalStateException` that names the method: a method that takes parameters ("Only no-arg methods may be annotated with @Scheduled"), an expression that doesn't parse, `initialDelay` combined with `cron`, and a `private` method on a proxied bean, for example one whose class also uses `@Transactional` ("Switch its visibility to package or protected", [AopUtils.java](https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-aop/src/main/java/org/springframework/aop/support/AopUtils.java#L147-L160)). The rest are silent:

| Cause                                    | What happens                                                                                                                                                                                                                                                                                             |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No `@EnableScheduling`                   | Boot doesn't enable scheduling on its own, and the annotations are ignored.                                                                                                                                                                                                                              |
| The class isn't a bean                   | Instances made with `new`, classes outside the [component-scanned](https://docs.spring.io/spring-boot/4.1/reference/using/using-the-springbootapplication-annotation.html) `@SpringBootApplication` package, and beans behind an inactive `@Profile` or condition aren't scheduled.                      |
| The schedule resolves to `-`             | The task isn't registered, so it's also missing from `/actuator/scheduledtasks`.                                                                                                                                                                                                                         |
| Another task holds the thread            | A thread dump shows what `scheduling-1` is running.                                                                                                                                                                                                                                                      |
| The wrong zone                           | The task runs, at a different hour than you expect.                                                                                                                                                                                                                                                      |
| The app was down at the scheduled time   | A cron trigger starts from the next match after startup ([CronTrigger.java](https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-context/src/main/java/org/springframework/scheduling/support/CronTrigger.java#L124-L143)); missed occurrences are not run.                           |
| It throws every time                     | It ran. Spring logged "Unexpected error occurred in scheduled task" at ERROR and runs it again next time ([TaskUtils.java](https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-context/src/main/java/org/springframework/scheduling/support/TaskUtils.java#L41-L96)).                |
| A `final` method on a CGLIB-proxied bean | The call runs on the proxy, which Spring warns "might lead to NPEs against uninitialized fields in the proxy instance" ([CglibAopProxy.java](https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-aop/src/main/java/org/springframework/aop/framework/CglibAopProxy.java#L296-L304)). |

## Overlap Within One App and Across Replicas

On the default scheduler, a cron task doesn't overlap itself: next times are computed from the previous completion, "therefore, overlapping executions won't occur", and an occurrence due during a long run is skipped ([CronTrigger](https://docs.spring.io/spring-framework/docs/7.0.10/javadoc-api/org/springframework/scheduling/support/CronTrigger.html)). Virtual threads and `@Async` are the exceptions, as is a method with several `@Scheduled` annotations, whose triggers "may overlap" ([reference](https://docs.spring.io/spring-framework/reference/7.0/integration/scheduling.html#scheduling-annotation-support-scheduled)). [Preventing overlapping cron jobs](https://cronradar.com/blog/prevent-overlapping-cron-jobs) covers lock policies.

Nothing coordinates replicas: three instances run each task three times, and a rolling deploy runs old and new instances together for a while. Two common fixes:

- **Schedule in one deployment.** Set the cron properties to `-` on the web replicas and leave them on in a single worker. That worker is then the one place the jobs stop when it's down.
- **ShedLock.** `@SchedulerLock(name = "nightly-invoices", lockAtMostFor = "2h", lockAtLeastFor = "5m")` takes a lock in a shared store, and nodes that find it held skip the run rather than wait. `lockAtMostFor` frees a lock whose holder died and must be "much longer than normal execution time"; `lockAtLeastFor` holds the lock after a short run so a node with a slightly late clock doesn't repeat it ([ShedLock 7.10.1 README](https://github.com/lukas-krecan/ShedLock/blob/shedlock-parent-7.10.1/README.md)). In the default mode, "final and non-public methods are not proxied", so the lock doesn't apply to them. [Distributed cron](https://cronradar.com/blog/distributed-cron) has the full setup, database time, and the run ShedLock loses when a node dies.

## What Spring Boot Shows: Actuator and Micrometer

With Actuator, `GET /actuator/scheduledtasks` lists each task's target method and expression or interval. Since Boot 3.4 it also shows `nextExecution.time` and `lastExecution`: a status of `STARTED`, `SUCCESS`, or `ERROR`, its time, and the exception type and message ([endpoint reference](https://docs.spring.io/spring-boot/4.1/api/rest/actuator/scheduledtasks.html)). Only `health` is exposed over HTTP by default ([endpoints](https://docs.spring.io/spring-boot/4.1/reference/actuator/endpoints.html)):

```yaml
management:
  endpoints:
    web:
      exposure:
        include: 'health,scheduledtasks'
```

It's one instance's in-memory state, cleared on restart, and exception messages can carry data, so keep it behind authentication.

Each run also creates a `tasks.scheduled.execution` observation (Spring Framework 6.1+), tagged with `code.namespace`, `code.function`, and `outcome` (`SUCCESS`, `ERROR`, or `UNKNOWN`) ([observability reference](https://docs.spring.io/spring-framework/reference/7.0/integration/observability.html#observability.tasks-scheduled)). Spring Boot connects it whenever an `ObservationRegistry` exists, which the Actuator starter provides ([ScheduledTasksObservationAutoConfiguration.java](https://github.com/spring-projects/spring-boot/blob/v4.1.2/module/spring-boot-micrometer-observation/src/main/java/org/springframework/boot/micrometer/observation/autoconfigure/ScheduledTasksObservationAutoConfiguration.java)). Micrometer 1.17 records a `tasks.scheduled.execution` timer and a `tasks.scheduled.execution.active` long-task timer for runs in progress ([DefaultMeterObservationHandler.java](https://github.com/micrometer-metrics/micrometer/blob/v1.17.2/micrometer-core/src/main/java/io/micrometer/core/instrument/observation/DefaultMeterObservationHandler.java#L71-L92)), so a metrics alert on `outcome="ERROR"` catches failures.

Both live inside the app. A task that never fires records nothing, an app that's down exports nothing, and an absence alert needs a rule per task that encodes its schedule. A [dead man's switch](https://cronradar.com/blog/cron-dead-mans-switch) covers that gap: each run checks in, and a service outside the app alerts when a check-in is late.

## Alert on Missed, Failed, and Stuck Runs with HTTP Check-Ins

There is no CronRadar SDK for Java or Spring and no auto-discovery of `@Scheduled` methods, so each task reports with its own HTTP check-in. This helper uses only the JDK's HTTP client and SLF4J, which Spring Boot already includes:

```java
// CronRadarCheckins.java
package com.example.billing.monitoring;

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Wraps a scheduled task in CronRadar check-ins. Monitoring problems are logged, never thrown. */
public final class CronRadarCheckins {

    @FunctionalInterface
    public interface Work {
        void run() throws Exception;
    }

    private static final Logger log = LoggerFactory.getLogger(CronRadarCheckins.class);
    private static final Duration TIMEOUT = Duration.ofSeconds(5);
    private static final HttpClient CLIENT = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();

    private CronRadarCheckins() {
    }

    /** Sends start, runs the work, then sends complete or fail. Rethrows whatever the work throws. */
    public static void run(String monitorKey, String schedule, int gracePeriodSeconds, Work work)
            throws Exception {
        Map<String, String> params = new LinkedHashMap<>();
        params.put("schedule", schedule); // five-field cron, UTC
        params.put("gracePeriod", Integer.toString(gracePeriodSeconds));
        params.put("run_id", UUID.randomUUID().toString());

        report(monitorKey, "start", params);
        try {
            work.run();
        } catch (Throwable t) {
            // The class name only: exception messages can carry customer data.
            params.put("message", "Threw " + t.getClass().getName());
            report(monitorKey, "fail", params);
            throw t;
        }
        report(monitorKey, "complete", params);
    }

    private static void report(String monitorKey, String event, Map<String, String> params) {
        String apiKey = System.getenv("CRONRADAR_API_KEY");
        if (apiKey == null || apiKey.isBlank()) {
            log.warn("CRONRADAR_API_KEY is not set; skipped {} check-in for {}", event, monitorKey);
            return;
        }
        try {
            String query = params.entrySet().stream()
                    .map(e -> encode(e.getKey()) + "=" + encode(e.getValue()))
                    .collect(Collectors.joining("&"));
            URI uri = URI.create("https://cron.life/api/v1/ping/" + encode(monitorKey) + "/" + event + "?" + query);
            String credentials = Base64.getEncoder()
                    .encodeToString((apiKey + ":").getBytes(StandardCharsets.UTF_8));
            HttpRequest request = HttpRequest.newBuilder(uri)
                    .timeout(TIMEOUT)
                    .header("Authorization", "Basic " + credentials)
                    .POST(HttpRequest.BodyPublishers.noBody())
                    .build();
            HttpResponse<Void> response = CLIENT.send(request, HttpResponse.BodyHandlers.discarding());
            if (response.statusCode() >= 300) {
                log.warn("CronRadar {} check-in for {} returned HTTP {}", event, monitorKey, response.statusCode());
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt(); // leave the interrupt for the task and for shutdown
            log.warn("CronRadar {} check-in for {} was interrupted", event, monitorKey);
        } catch (Exception e) {
            log.warn("CronRadar {} check-in for {} failed: {}", event, monitorKey, e.toString());
        }
    }

    private static String encode(String value) {
        return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
    }
}
```

Wrap each task's body in it, and give the work a deadline of its own:

```java
// SessionCleanup.java
package com.example.billing.jobs;

import javax.sql.DataSource;

import com.example.billing.monitoring.CronRadarCheckins;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class SessionCleanup {

    private final JdbcTemplate jdbc;

    public SessionCleanup(DataSource dataSource) {
        this.jdbc = new JdbcTemplate(dataSource);
        this.jdbc.setQueryTimeout(600); // seconds: a stuck DELETE throws and is reported as a fail
    }

    // Spring: 03:30:00 UTC daily. CronRadar: the same time in five fields, with
    // 600 seconds of grace for a start delayed by another task on the scheduler thread.
    @Scheduled(cron = "0 30 3 * * *", zone = "UTC")
    public void deleteExpiredSessions() throws Exception {
        CronRadarCheckins.run("session-cleanup", "30 3 * * *", 600, () ->
                jdbc.update("DELETE FROM user_sessions WHERE expires_at < now()"));
    }
}
```

The wrapper reports `start`, runs the work, then reports `complete`, or reports `fail` and rethrows the original exception, so Spring still logs it and records `ERROR`. The connect and request timeouts are five seconds each, and monitoring errors are logged, never thrown, so an unreachable cron.life can't hang a run or change its result. Use one CronRadar application and key per environment. Each run sends two requests against a limit of 60 a minute and 1,000 an hour per key, so many every-minute tasks on one key can draw `429` responses.

Sending `schedule` every time makes the monitor self-healing: the first run creates it, and the next run recreates it if it's deleted. `schedule` and `gracePeriod` apply only when a check-in creates the monitor; change them later in the dashboard's monitor settings. With ShedLock, call the helper inside the `@SchedulerLock` method so nodes that skip the run send nothing; with `@Async`, the helper times the real work.

A `fail` alerts immediately. Missed runs are checked once a minute: the monitor turns `warning` when the expected time passes and `critical`, with an incident, once it's past the grace period and at least two minutes late. Stuck detection needs the `start`: every check-in moves the expected time to the next occurrence, and if that time plus grace passes while the last event is a `start`, the monitor goes `critical` as stuck. On a `ThreadPoolTaskScheduler`, a hung run blocks its own next run, so the hang shows up after the next occurrence plus grace, and with one thread every other task goes missing too. Under virtual threads or `@Async`, the next `start` resets the window and a hang can go unflagged. A deadline inside the work, like the query timeout above, turns a hang into a `fail`; [cron job timeouts](https://cronradar.com/blog/cron-job-timeout) covers finding the call that hangs.

### Convert Spring's Schedule to Five-Field UTC

CronRadar reads [standard five-field cron in UTC](https://docs.cronradar.com/schedules), without seconds, `?`, macros, `L`, `W`, `#`, or `7` for Sunday, and, like Spring, requires both day fields to match. Drop the seconds field (the grace period absorbs a nonzero second) and rewrite the rest:

| Spring `@Scheduled`                        | CronRadar `schedule`                                                                                  |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `0 30 3 * * *`, `zone = "UTC"`             | `30 3 * * *`                                                                                          |
| `0 0 9 ? * MON-FRI`                        | `0 9 * * MON-FRI`                                                                                     |
| `0 0 9 ? * MON#1`                          | Change Spring to `0 0 9 1-7 * MON` and send `0 9 1-7 * 1`; both read it as the first Monday           |
| `0 0 23 L * *`                             | Run on `0 0 23 28-31 * *`, skip unless tomorrow is the 1st, check in every run, send `0 23 28-31 * *` |
| `0 0 9 * * *`, `zone = "America/New_York"` | `0 13 * * *` with a grace period of at least 3,600 seconds plus the run time                          |
| `*/30 * * * * *`                           | None: five fields can't express more than one run a minute                                            |
| `fixedRate` or `fixedDelay`                | None: intervals aren't accepted, so switch to a cron trigger such as `0 */10 * * * *`                 |

A `zone` with daylight saving time moves the run's UTC time by an hour twice a year, so send the earlier UTC time with a grace period of at least an hour plus the usual run time. 09:00 in New York is 13:00 UTC in summer and 14:00 in winter, so in winter the monitor shows `warning` for the hour before each run. A local time inside the skipped spring-forward hour doesn't run that day: Spring's tests expect a 02:10 job in Europe/Amsterdam to skip March 31, 2013 ([CronExpressionTests.java](https://github.com/spring-projects/spring-framework/blob/v7.0.10/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java#L1347-L1353)), and CronRadar correctly reports that day as missed. `zone = "UTC"` avoids all of this.

## Frequently Asked Questions

### Why is my Spring @Scheduled task not running?

Usually `@EnableScheduling` is missing, the class isn't a Spring bean, the cron property resolves to `-`, or another task is holding Spring Boot's single scheduler thread. Runs due while the app was down are not made up. A task that throws did run: look for "Unexpected error occurred in scheduled task" in the log.

### What cron format does Spring use?

Six fields with seconds first: second, minute, hour, day of month, month, day of week. `0 0 2 * * *` is 02:00:00 daily. Spring adds `?`, `L`, `W`, `#`, and macros such as `@daily`, rejects five-field expressions at startup, and requires a date to match both day fields when both are restricted.

### How do I run @Scheduled tasks in parallel?

Raise `spring.task.scheduling.pool.size` above its default of 1; each task still never overlaps itself. On Java 21+, `spring.threads.virtual.enabled=true` gives each execution its own virtual thread, but cron and fixed-rate tasks can then overlap themselves and fixed-delay tasks share one thread. `@Async` on the method also frees the scheduler, with the same overlap.

### How do I stop @Scheduled running on every instance?

Either schedule in one deployment, by setting the cron properties to `-` everywhere else, or add ShedLock's `@SchedulerLock` so each run first takes a lock in a shared database or Redis and the other nodes skip it. Make the method public and non-final, set `lockAtMostFor` well above the longest run, and make the job safe to run twice.

### How do I monitor Spring scheduled tasks?

Inside the app, Actuator's `scheduledtasks` endpoint shows each task's next run and last outcome, and the `tasks.scheduled.execution` timer counts runs by outcome. To catch runs that never happen, have each task check in with a monitor outside the app. With CronRadar, `start`, `complete`, and `fail` check-ins carrying the five-field UTC schedule alert on missed, failed, and stuck runs.
