Scheduling, cron and time-based triggers

Cron syntax and its five fields, why schedules must name a zone, misfires and catch-up behaviour, at-least-once versus exactly-once, and durable timers.

Reading a cron expression

field 1  minute        0-59
field 2  hour          0-23
field 3  day of month  1-31
field 4  month         1-12
field 5  day of week   0-7 (0 and 7 are Sunday)
         command

* * * * *
0 9 * * 1-5       09:00 every weekday
*/15 * * * *      every fifteen minutes
0 0 1 * *         midnight on the first of each month
0 4 * * 0         04:00 every Sunday
30 2 * * *        02:30 daily  -- dangerous in a DST zone
  • Either day-of-month or day-of-week may be restricted; when both are, most implementations OR them rather than AND them.
  • */15 means every 15 units within the field, not every 15 minutes from an arbitrary start.
  • Many schedulers add a sixth field for seconds and a seventh for year; a standard five-field parser will reject those.
  • Named values like MON and JAN are common extensions, not portable.

Misfires, catch-up and clusters

BehaviourMeaningTypical setting
Skip misfiresA missed run is droppedFrequent, idempotent jobs
Fire once immediatelyOne catch-up run, then resumeHourly aggregations
Fire all missed runsBackfill every missed occurrenceBilling periods with per-period state
Bounded catch-upFire a limited number, then resyncJobs that must not stampede
Run in a clusterOnly one node executesRequires a lock or leader election
# systemd timer: OnCalendar with an explicit zone, Persistent handles downtime
# /etc/systemd/system/report.timer
# [Timer]
# OnCalendar=Mon..Fri 04:00
# Persistent=true
# RandomizedDelaySec=300

systemctl list-timers --all
systemctl status report.timer

An in-process scheduler is not durable. If the process is down at the scheduled moment, the run never happens and nothing records that it should have. Use a system cron, a systemd timer or a queue-backed scheduler for anything that matters.

Delivery semantics and durable delays

  • Scheduled execution is at-least-once in practice: a crash after work but before the acknowledgement causes a repeat.
  • Make jobs idempotent — key the effect on the scheduled instant, not on wall-clock arrival time.
  • Take a lock named by the schedule instant so a cluster cannot double-run.
  • Add jitter to avoid a thundering herd at the top of the hour.
  • For one-off delays, use a durable timer in a queue rather than an in-memory setTimeout, which vanishes on restart.
# idempotent daily job keyed on the intended date, not on when it ran
def run_daily(run_date: date):
    with db.transaction():
        if already_done(run_date):        # a unique index on (job, run_date)
            return
        process(run_date)
        mark_done(run_date)
💡
Do not schedule a job at 02:30 local time and assume it ran. Pick a time outside the daylight-saving window, express the schedule in UTC when possible, and record the intended run instant so a catch-up can tell whether the work happened.

FAQ

Does cron have a time zone?
Traditional cron uses the system time zone, which makes a schedule environment dependent. Modern schedulers let you name a zone per job; do that, and prefer UTC for infrastructure work.
How do I run something every 90 minutes?
Cron cannot express that cleanly. Use a queue-based scheduler with an interval, or a timer with an explicit delay, rather than approximating it with cron fields.

Daylight saving and its edge cases Monotonic clocks and elapsed time

Last refreshed 2026-09-18.