Twice a year, DST transitions silently corrupt scheduled task logic. The bugs are predictable, preventable, and almost always caused by the same mistake: scheduling in wall-clock time instead of UTC. In spring, when clocks advance one hour, a job scheduled for 2:30 AM doesn't run—2:30 AM doesn't exist. In autumn, when clocks fall back one hour, a job scheduled for 1:30 AM runs twice.
The Two Failure Modes
Spring forward: Clocks advance from 1:59:59 AM to 3:00:00 AM. Any cron expression that would fire between 2:00 AM and 2:59 AM is silently skipped. If your billing job, backup, or report generation runs at 2:00 AM in a named timezone, it doesn't run on that day. No error. No alert. No log entry. Silence.
Fall back: Clocks retreat from 1:59:59 AM to 1:00:00 AM. A job scheduled for 1:30 AM fires once on the first pass through 1:30 AM, then fires again when the clock returns to 1:30 AM after the rollback. If your job is a charge attempt, a deduplication run, or a database migration, running it twice is not neutral. Many scheduled task systems don't have built-in idempotency—they assume the schedule fires once per intended interval.
UTC Is the Only Correct Default
Schedule in UTC. UTC doesn't have DST transitions. A job scheduled for 0 2 * * * in UTC fires at exactly the same UTC instant every day, regardless of what the local clock is doing. If users need to see scheduled times in their local timezone, convert for display only—store and compute in UTC.
This sounds obvious. It's widely ignored because:
- Cron syntax accepts timezone specifications in many implementations, making it easy to do the wrong thing.
- Most jobs run during business hours, when no DST transition occurs, so the bug stays dormant.
- Test environments often don't simulate DST transitions.
- The failure is silent—no exception, just a missing or duplicate execution.
The Named Timezone Trap
Some scheduling systems (Kubernetes CronJobs, AWS EventBridge Scheduler, many cron daemon implementations) support named timezone specifications: CRON_TZ=America/New_York 30 2 * * *. This lets you express "2:30 AM New York time," which sounds like what you want and is, in fact, the bug. You've now committed to firing at a wall-clock time that may not exist twice a year.
Named timezones are useful for display and conversion. They are wrong for scheduling anchor points. If the business requirement is "send the weekly digest every Monday at 9 AM New York time," the correct implementation is: fire the job at a UTC time corresponding to 9 AM New York standard time (14:00 UTC in winter, 13:00 UTC in summer), and accept that the job fires at 8 AM New York time during DST. Or fire at 14:00 UTC year-round and display in local time. Never use a named timezone in the cron expression itself for a job where timing precision matters.
Wall-Clock vs Monotonic Scheduling
There's a subtler variant: systems that use wall-clock time for interval tracking. "Run every 6 hours" sounds unambiguous. If the implementation anchors to wall-clock time and a DST transition occurs during a 6-hour window, the actual interval is either 5 hours or 7 hours depending on direction. For jobs that must not overlap (database compactions, large report generations), this matters.
Correct interval scheduling uses monotonic time—elapsed time since last run, measured in seconds without reference to calendar time—or schedules future runs in UTC at fixed intervals from the last UTC timestamp.
Container Timezone Inheritance
Containers complicate this further. A container's timezone is determined by the TZ environment variable or by the /etc/localtime symlink inside the container. An image built without timezone data (many minimal images) may behave unpredictably when a named timezone is specified. A container that inherits the host's timezone will behave differently in production (UTC server) than in development (America/Denver developer laptop). Scheduled tasks inside containers that rely on host timezone should be tested with TZ=UTC explicitly set.
Practical Checklist
- All cron expressions use UTC. No named timezone in the cron line itself.
- Scheduled task records store the next-run time as a UTC timestamp, not a wall-clock expression.
- Jobs are idempotent for at-least-once execution: they check their own completion before doing work, or use a distributed lock.
- Container environments set
TZ=UTCexplicitly. - If a job must fire "at 9 AM user local time," schedule it from application code using the user's stored timezone preference to compute a UTC fire time—don't put the timezone in the cron expression.
The twice-a-year failures don't announce themselves. The billing job that didn't run on a Sunday in March is discovered in April when a customer disputes a charge. The deduplication run that fired twice in November is discovered when the counts don't reconcile. Schedule in UTC, treat wall-clock time as display-only, and the problem disappears entirely.
Building a scheduled task system? builds.anethoth.com tracks indie SaaS projects with transparent revenue — including tools that solve exactly this kind of infrastructure problem.
More from Anethoth: anethoth.com · builds.anethoth.com