Cron looks simple enough that most teams under-invest in it, right up until a job doubles up, overlaps with itself, or silently stops running for three weeks before anyone notices. The syntax is the easy 10%; the reliability patterns — locking, timezone handling, alerting on absence rather than just failure — are the part that actually determines whether a scheduled job is trustworthy.
The five-field syntax and its sharp edges
Standard cron uses five fields — minute, hour, day-of-month, month, day-of-week — and the sharp edge most people hit is that day-of-month and day-of-week are OR'd together, not AND'd, when both are restricted from *. 0 9 15 * 1 does not mean "9am on the 15th, if it's a Monday" — it means "9am on the 15th, OR every Monday at 9am." If you need "the first Monday of the month," you can't express that with a single cron expression; you need either a day-of-month range combined with day-of-week filtering in your job logic, or a scheduler that supports a richer expression (some systems, like AWS EventBridge, support explicit day-of-month-and-weekday combinations that standard cron does not).
# Every day at 2:30am
30 2 * * *
# Every 15 minutes
*/15 * * * *
# Weekdays only, 6pm
0 18 * * 1-5
# First of the month, 1am
0 1 1 * *
# Every 6 hours, on the hour
0 */6 * * *
Timezones are the most common production bug
System cron traditionally runs in the server's local timezone, which is fine until you deploy across regions, or the server's timezone gets changed by an OS update, or you migrate to a scheduler (Kubernetes CronJob, cloud scheduler service) that defaults to UTC while your old cron ran in local time. A job scheduled for "2am maintenance window" silently shifting by several hours because of a timezone default change is one of the most common and hardest-to-notice cron regressions. Pin the timezone explicitly wherever the scheduler supports it, and treat "what timezone does this run in" as a required piece of documentation next to every schedule.
A job scheduled for a fixed local wall-clock time can run twice or not at all on the days clocks change, depending on the scheduler's DST handling. If a job's correctness depends on running exactly once per day, either schedule in UTC (unaffected by DST) or make the job itself idempotent so a double-run or skipped run doesn't corrupt state.
Prevent overlapping runs
Cron doesn't know or care whether the previous invocation of a job finished. A job that normally takes 2 minutes but occasionally takes 20 (a slow API dependency, a locked database row) will, on a 5-minute schedule, eventually start a second instance while the first is still running — and now you have two processes touching the same data concurrently. The fix is a lock, not a longer interval: a file lock (flock) for single-host cron, or a distributed lock (a row in the database with a TTL, a Redis key with `SET NX EX`) for anything that might run on multiple hosts or in a scheduler that doesn't guarantee single-instance execution.
*/5 * * * * /usr/bin/flock -n /tmp/sync-invoices.lock /opt/scripts/sync-invoices.sh
Alert on absence, not just on failure
A job that crashes loudly is easy to catch — it exits non-zero, your monitoring flags it. The dangerous failure mode is the job that stops running entirely: a cron daemon that silently failed to reload after a config change, a Kubernetes CronJob whose suspend flag got flipped by an unrelated change, a scheduler entry that got deleted in a refactor. None of these produce an error, because nothing ran to fail. Dead man's switch monitoring (Healthchecks.io, Cronitor, or a simple "expect a heartbeat within N minutes" check against your own monitoring stack) catches this by alerting when an expected check-in *doesn't* arrive, rather than only alerting on an explicit failure signal.
A job that only logs on failure gives you no way to distinguish "hasn't run yet" from "ran and succeeded silently" from "stuck mid-run." Log a start timestamp, a completion timestamp, and the outcome on every run, success or failure — it's cheap and it's what makes the dead-man's-switch pattern and duration-drift alerting possible in the first place.
| Failure mode | Cause | Mitigation |
|---|---|---|
| Job runs twice concurrently | Run time occasionally exceeds interval | flock or distributed lock |
| Job runs at the wrong wall-clock time | Timezone default drift, DST | Pin timezone explicitly, prefer UTC scheduling |
| Job silently stops running | Scheduler misconfig, deleted entry, suspended CronJob | Dead man's switch / heartbeat monitoring |
| Job fails but nobody notices | No alerting wired to exit code | Alert on non-zero exit, not just log the error |
Wrapping up
The syntax rarely causes incidents; the operational gaps do. Add a lock before a job's first overlapping run causes a data problem, pin the timezone explicitly rather than trusting a default, and set up absence monitoring so a silently-stopped job gets caught in minutes instead of discovered three weeks later when someone asks why a report never arrived.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.