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.
*/15means 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
MONandJANare common extensions, not portable.
Misfires, catch-up and clusters
| Behaviour | Meaning | Typical setting |
|---|---|---|
| Skip misfires | A missed run is dropped | Frequent, idempotent jobs |
| Fire once immediately | One catch-up run, then resume | Hourly aggregations |
| Fire all missed runs | Backfill every missed occurrence | Billing periods with per-period state |
| Bounded catch-up | Fire a limited number, then resync | Jobs that must not stampede |
| Run in a cluster | Only one node executes | Requires 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.timerAn 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.
Related
Daylight saving and its edge cases Monotonic clocks and elapsed time
Last refreshed 2026-09-18.