Available Hire Me
← All Writing Spring Boot

Scheduled Tasks Done Right — @Scheduled Pitfalls, Timezones, and Thread Pools

How to run scheduled jobs in Spring Boot without surprises — fixedRate vs fixedDelay, cron DST traps, the single-threaded scheduler, and silent failures.

A daily report pipeline looks like the easiest part of the system: one @Scheduled annotation, commit, done. Then the report comes out at the wrong time, a run overlaps the previous one, and a slow job silently stalls every other scheduled task on the box. Scheduling in Spring Boot is one line of code and a surprisingly deep set of semantics behind it — the annotation is trivial, the behaviour around it is where production incidents come from.

This post covers the decisions that actually matter when you schedule work in a single Spring Boot service: the three scheduling modes, what cron really means in Spring, the default thread pool trap, and how to make failures visible. If you’re running the same job on multiple instances, you also need distributed locking — I covered that separately in distributed cron jobs with ShedLock. This post is about getting one instance right first.

The three scheduling modes

@Scheduled gives you three modes, and each one has different timing semantics:

  • fixedDelay — wait N milliseconds after the previous execution finishes, then run again. Executions are strictly serial: a slow run pushes the next run later.
  • fixedRate — fire every N milliseconds measured from the start of the previous execution. If a run takes longer than N, executions can pile up.
  • cron — calendar-based, for jobs that must run at a wall-clock time (daily reports, overnight batches, market data snapshots).

The mode you choose is a statement about what matters: fixedDelay says “never overlap”, fixedRate says “keep the cadence”, cron says “this must happen at 07:30”. Mixing them up is the most common scheduling bug I see — a monitoring heartbeat written as a cron expression that fires at the wrong second, or a data sync written as fixedRate that silently stacks up during a slow afternoon.

Why fixedRate overlaps — and how to stop it

fixedRate doesn’t wait for the previous run to finish. Consider a job that takes 90 seconds with fixedRate = 60000:

@Scheduled(fixedRate = 60_000)
public void refreshMarketData() {
    // takes 90s on a busy day
}

Every minute a new execution starts, even though the previous one is still running. Two executions of the same job now touch the same state — duplicated API calls, double-inserted rows, corrupt caches.

The guard is to make the execution serial yourself, regardless of the pool size (more on pools below):

private final AtomicBoolean running = new AtomicBoolean();

@Scheduled(fixedRate = 60_000)
public void refreshMarketData() {
    if (!running.compareAndSet(false, true)) {
        return; // previous run still going — skip this tick
    }
    try {
        // ... job body ...
    } finally {
        running.set(false);
    }
}

Or use fixedDelay when the cadence genuinely doesn’t matter and serial execution does. For a report that must be fresh rather than precise, fixedDelay is almost always the right answer.

Cron is not the cron you know

Spring’s cron is six fields, with seconds first:

second minute hour day-of-month month day-of-week
0      30     7    *             *      *

Unix cron is five fields with minutes first. People migrating from crontab habitually write a five-field string like "0 2 * * *" meaning “02:00 daily”. Spring parses that as: second = 0, minute = 2, hour = * — i.e. every hour at two minutes past. Your “daily” job runs 24 times a day and nobody notices until the side effects add up.

The second trap is timezone. Spring evaluates cron in the JVM’s default timezone unless you say otherwise:

@Scheduled(cron = "0 30 7 * * *", zone = "Europe/London")
public void generateDailyReport() { ... }

If your servers run in UTC and your users are in the UK, an unzoned "0 30 7 * * *" fires at 08:30 BST — an hour late, and the “late report” tickets start. Worse, across the March and October DST boundaries a naive job can run twice or not at all on the transition night. Always pin zone explicitly rather than inheriting whatever the container’s TZ happens to be.

The single-threaded scheduler trap

The default scheduler Spring Boot configures for @Scheduled has a pool size of one. That is deliberate — it makes overlapping runs impossible — but it means one slow job delays every other scheduled task in the application. Your overnight batch takes 20 minutes and suddenly the 09:00 health-check heartbeat is late too.

Fix it by defining a TaskScheduler bean with a sensible pool and a name prefix for thread dumps:

@Bean
public TaskScheduler taskScheduler() {
    ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
    scheduler.setPoolSize(4);
    scheduler.setThreadNamePrefix("scheduled-");
    scheduler.initialize();
    return scheduler;
}

But don’t confuse “more threads” with “correctness”. A larger pool enables concurrent execution of the same job, which is exactly the overlap problem above — the pool size and the per-job serialisation guard have to be designed together.

Failures are silent

An exception thrown inside a @Scheduled method is logged and swallowed. There is no retry, no alert, no dead-letter queue — the next scheduled run just happens and, if the condition persists, fails again. A report job that throws at 07:30 every day produces no error page, only a quiet stack trace in the logs and a missing report.

Make failures visible:

@Scheduled(cron = "0 30 7 * * *", zone = "Europe/London")
public void generateDailyReport() {
    try {
        // ... generate and publish report ...
    } catch (Exception e) {
        failureCounter.increment();
        log.error("Daily report generation failed", e);
        throw e; // or alert, then decide whether to rethrow
    }
}

A Micrometer counter fed by the catch block gives you an alertable metric, and Spring Boot Actuator’s /actuator/scheduledtasks endpoint shows every registered task with its trigger — the fastest way to confirm what is scheduled and when.

Configuration that prevents the whole class

Two small pieces of setup remove most of the failure modes above before they exist:

  • Drive cron from configuration, not from a hardcoded annotation. A SchedulingConfigurer lets you change the schedule without redeploying — and lets you pause a job by setting the cron to a never-matching value during an incident.
  • Give long-running work its own executor. If a scheduled job does heavy blocking work, run it on a dedicated @Async executor so the scheduler pool stays responsive for short tasks. The scheduler’s job is to say “start now” — not to host a 20-minute batch.

Checklist before you ship a scheduled task

  • fixedDelay unless you explicitly need cadence or wall-clock time.
  • Six-field cron, never five — and a unit test that asserts the next few fire times if the schedule is load-bearing.
  • zone set explicitly on every cron task.
  • Pool size chosen deliberately, with per-job guards against overlap.
  • Failures counted and alertable, not just logged.
  • The schedule itself visible via /actuator/scheduledtasks.

Scheduled code looks trivial, and that’s exactly why the subtle traps — the five-field cron, the unzoned DST boundary, the shared single thread — survive into production. Get the semantics right once and the daily pipeline runs itself.

If you’re building a scheduled pipeline in Spring Boot and want to get the failure modes right the first time, get in touch.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 25+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.