Spring @Scheduled in containers — the timezone bug you will hit eventually
Your laptop is not your production container. JVM default timezone is UTC there. ShedLock is mandatory for multi-instance. The DoW numbering changed in 5.3. Here is the rest.
Verified by maintainer·Last updated
Spring's @Scheduled is the path of least resistance for "just run this method every X". It works great on a developer laptop. It breaks in subtle ways the moment you containerize and deploy to a Kubernetes Deployment with replicas > 1. Here is what trips teams up.
The JVM default timezone is the container's timezone
Your laptop runs in Europe/Berlin (or wherever you live). Your container is based on a barebones Linux image with no TZ data installed, defaulting to UTC. The same @Scheduled(cron = "0 0 9 * * MON-FRI") fires at 09:00 local on your laptop and 09:00 UTC in production — those are different instants.
The fix: always set zone explicitly on every @Scheduled:
@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Europe/Berlin")
public void morningKickoff() { /* ... */ }If you have many scheduled methods, externalize the zone too:
@Scheduled(cron = "${app.cron.morning}", zone = "${app.cron.zone:UTC}")
public void morningKickoff() { /* ... */ }Day-of-week numbering changed in 5.3
Spring used to use Unix-style 0 = Sunday. As of Spring 5.3+, it uses 1 = Monday, 7 = Sunday — yet another convention, different from Unix and from Quartz / AWS. If you are porting an expression from a Linux crontab, the weekday field needs translating twice: once for the new numbering, once for the dialect difference.
When in doubt, use the alphabetic form (MON-FRI). It is the same string on every platform.
Six fields, no year
Spring @Scheduled uses 6 fields: second minute hour day-of-month month day-of-week. There is no year field — that is Quartz, not Spring. If you have a Quartz expression with seven fields, drop the year before pasting it into @Scheduled.
Multi-instance deployments fire every instance
A Spring Boot service deployed to Kubernetes with replicas: 3 will fire each @Scheduled method on every instance. If your job sends an email, sends three emails. If it issues a database insert, you get three inserts (or one and two errors). Spring's scheduler is per-JVM, not per-cluster.
The standard fix is ShedLock. It uses a shared store (database row, Redis key, ZooKeeper node) as a distributed lock: whichever instance grabs the lock first runs the job, the others early-return.
@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Europe/Berlin")
@SchedulerLock(name = "morningKickoff", lockAtMostFor = "10m", lockAtLeastFor = "30s")
public void morningKickoff() { /* ... */ }lockAtMostFor is a safety net for crashes: if the holding instance dies mid-job, the lock is released after this many minutes so another instance can recover. lockAtLeastFor protects against fast clock skew between nodes — even if the job finishes in 5 seconds, no other instance picks it up for 30. Configure both generously larger than your normal job duration.
The disable-by-property idiom
Spring accepts a magic value in the cron field that disables the schedule entirely:
@Scheduled(cron = "${app.cron.cleanup:-}", zone = "UTC")
public void cleanup() { /* ... */ }If app.cron.cleanup is unset, the placeholder defaults to -, and Spring treats - as "never run". This is the cleanest way to have a feature-flag on a scheduled job without an @ConditionalOnProperty dance.
Application startup order — scheduled jobs vs lazy beans
@Scheduled methods become active when the application context finishes refreshing. If a scheduled method depends on a @Lazy bean and the schedule fires at startup (or shortly after), the lazy bean is initialized on the scheduler thread — which can deadlock if that bean does I/O on the same thread pool.
The fix is either: (a) don't mark beans @Lazy if a scheduler touches them, or (b) make the scheduled method async-launch the work onto a separate executor.
Graceful shutdown — mid-running jobs and SIGTERM
When Kubernetes sends SIGTERM to your pod (rolling deployment, autoscaler scale-down), Spring Boot starts shutting down the context. By default, that does not wait for running scheduled methods to finish. A scheduled job mid-database-write can be cut off, leaving partial state.
Configure graceful task termination:
spring:
task:
scheduling:
shutdown:
await-termination: true
await-termination-period: 60sCombined with a Kubernetes terminationGracePeriodSeconds: 90 on the Deployment, this gives mid-running jobs up to 60 seconds to finish before the JVM exits. Set the K8s grace period at least 30 seconds higher than the Spring one so the pod isn't SIGKILLed while Spring is still waiting.
When to graduate from @Scheduled to Quartz
@Scheduled is fine for "run this method daily" with one instance (or with ShedLock). It is the wrong tool when you need any of:
- Persistent schedules. Cron strings live in code and require redeploy to change. Quartz can store schedules in a JDBC store, editable at runtime.
- Misfire policies. What happens if the JVM was down when a job should have fired? Spring drops it. Quartz has explicit misfire-handling strategies per trigger.
- Coordinated schedules. "Run B 30 seconds after A finishes." Spring cannot express this. Quartz can.
- L, W, # special characters. Spring rejects them. Quartz supports them natively.
If you outgrow @Scheduled, integrate Quartz directly via spring-boot-starter-quartz. The graduation path is well-trodden and the configuration is incremental — Quartz coexists with @Scheduled methods, you don't have to migrate everything at once.