The midnight pile-up
Look at the crontabs across a fleet and a pattern shows up fast. Almost everything runs at 0 0 * * *, 0 * * * *, or */5 * * * *. People pick round numbers, and every host syncs its clock to the same NTP source, so thousands of unrelated jobs fire in the same second.
Most nights nothing breaks. Then the backups, the log rotation, and the cache warmer all land on 00:00:00, the box runs out of memory, and two of them get killed. Nobody scheduled them to collide. The round numbers did it.
Why synchronized schedules bite
Resource contention on one host
A server running twelve jobs staggered across the hour barely notices them. The same twelve at 0 0 * * * fight for CPU, RAM, and disk at once. The OOM killer picks victims semi-randomly, so the job that dies tonight is not the one that died last night, and the failure reads as intermittent rather than a scheduling problem.
Connection storms against shared dependencies
Jobs rarely run alone. They open database connections, hit an internal API, or read the same object store. Fifty jobs starting at once open fifty connections against a pool sized for ten. The database refuses the overflow, the losers fail, and you spend the morning on stack traces that all bottom out in "too many connections."
Rate limits and third-party APIs
External APIs meter by requests per second. A job that syncs with a payment provider is fine on its own. Run it across 200 tenants, all triggered by one 0 6 * * * line in shared code, and the burst trips the provider's rate limiter. From the outside, a legitimate batch and an attack look identical.
Cron's built-in randomization
Vixie-derived cron and cronie ship two features for this that plenty of people never touch.
RANDOM_DELAY
Set RANDOM_DELAY in the crontab and cronie delays each job by a random number of minutes up to that ceiling. RANDOM_DELAY=45 smears every job in the file across a 45-minute window instead of firing them on the minute. It is blunt, and it hits every job the same way, but it costs one line.
RANDOM_DELAY=45
0 0 * * * /usr/local/bin/backup.sh
0 0 * * * /usr/local/bin/rotate-logs.sh
The tilde range syntax
cronie also accepts a ~ range inside a field. 0 6~15 * * * picks a random minute between 6 and 15 past the hour, chosen when the crontab is parsed and held until reload. It is finer-grained: you set the window per job, and anything that must run early keeps its slot.
# random minute between :06 and :15, fixed until reload
0 6~15 * * * /usr/local/bin/report.sh
systemd timers spread load better
Run timer units instead of crontab lines and the controls get more precise, and they survive reboots correctly.
RandomizedDelaySec and FixedRandomDelay
RandomizedDelaySec= delays a timer by a random amount between zero and the value you set, re-rolled on every firing. It landed in systemd 229 and is the direct equivalent of RANDOM_DELAY. Add FixedRandomDelay=true, from systemd 247, and the delay is derived from the machine ID, so a host always lands on the same offset instead of jumping around.
RandomizedOffsetSec, new in systemd 258
systemd 258 shipped on 17 September 2025 with RandomizedOffsetSec=. RandomizedDelaySec re-randomizes before each run and can let a job drift. The offset does not: it applies one stable shift to the whole schedule. A weekly timer with an offset still fires exactly weekly, just at a per-machine time that is not midnight. Keep the cadence, break the synchronization.
[Timer]
OnCalendar=daily
RandomizedOffsetSec=1h
Persistent=true
AccuracySec
AccuracySec= defaults to one minute, so systemd already batches timers into one-minute windows to save power. Widen it to AccuracySec=30min on jobs that are not time-critical and systemd can place them anywhere in a 30-minute band, coalescing them with other wakeups.
Managed schedulers assume you will not sync
GitHub Actions delays at the top of the hour
GitHub's own docs warn that the schedule event can be delayed during periods of high load, and name the start of every hour as one of them. Push the load high enough and queued runs are dropped outright. The advice is to skip 0 * * * * and pick an odd minute like 17 * * * *, because everyone else scheduled on the hour.
Cloud schedulers
Cloudflare Workers Cron Triggers, AWS EventBridge Scheduler, and GCP Cloud Scheduler run your jobs on shared infrastructure. A Cloudflare incident on 8 July 2026 delayed some Workers cron triggers for roughly an hour. You do not control their capacity, and their busiest moments are the round numbers everyone reaches for. An off-peak minute is the one lever you hold.
Adding jitter when the scheduler has none
Sometimes the scheduler gives you nothing: a plain crontab without cronie's extensions, a container entrypoint, a language-level scheduler. Put the jitter in the job.
#!/bin/bash
set -euo pipefail
# sleep a random 0-600 seconds before doing any work
sleep $(( RANDOM % 600 ))
pg_dump mydb | gzip > /backups/mydb-$(date +%F).sql.gz
$RANDOM returns 0 to 32767, so the modulo caps the wait. Crude, but it turns a synchronized stampede into a smear across ten minutes without touching the schedule.
What jitter does to your monitoring
Jitter and monitoring pull in opposite directions, and this is where it goes wrong. A job that used to start at exactly 00:00 now starts somewhere in a 30-minute band, so a monitor told to expect a check-in at midnight fires a false alert every night the job lands late.
Size the window to the jitter, not the nominal schedule. Spread a daily job across 30 minutes, give it 10 minutes to run, and its success signal can legitimately arrive up to 40 minutes after midnight. A dead man's switch that alerts on the absence of a check-in within that window absorbs the jitter and still catches a missed run, because a job that never started never checks in.
Frequently asked questions about spreading scheduled load
Why do so many cron jobs run at midnight? People reach for round numbers, and every host syncs its clock to the same NTP source, so unrelated jobs fire in the same second. Alone each job is harmless, but together they compete for CPU, memory, database connections, and API rate limits, and the accidental collision is what makes the failures look intermittent.
What is RANDOM_DELAY in cron? RANDOM_DELAY is a cronie variable that delays every job in a crontab by a random number of minutes up to the ceiling you set. Setting it to 45 spreads all the jobs in that file across a 45-minute window instead of the same minute. It is coarse because it hits the whole file, but it is a single line.
How is RandomizedOffsetSec different from RandomizedDelaySec? RandomizedDelaySec re-rolls a fresh random delay before every run, which can let a job drift over time. RandomizedOffsetSec, added in systemd 258, applies one stable offset to the whole schedule, so a daily timer still fires exactly daily but at a per-machine time that is not midnight. Use the offset to break fleet-wide synchronization while keeping the cadence intact.
Does adding jitter break my cron monitoring? It does if the monitor expects a check-in at the exact scheduled time, because a jittered job legitimately arrives late. Size the window to the widest jitter plus the job's runtime rather than the nominal schedule. A dead man's switch that alerts on the absence of a success signal within that window absorbs the jitter and still catches a job that never ran.
Should I schedule GitHub Actions workflows at the top of the hour? No. GitHub documents that scheduled workflows can be delayed during high-load periods, and the start of every hour is one of them, with queued runs dropped under enough load. Pick an odd minute such as 17 past the hour, and if the timing genuinely matters, trigger the workflow from an external scheduler instead.
Further reading
- Cron Alternatives Compared: systemd Timers, Celery Beat, and More
- 10 Cron Job Best Practices Every Engineer Should Follow
- Time Zone Changes Are Moving When Your Cron Jobs Run
Conclusion: Synchronized schedules are self-inflicted. Round numbers and a shared clock turn a dozen harmless jobs into a stampede that fails intermittently and points at nobody. Spread the load with RANDOM_DELAY or a tilde range in cron, RandomizedOffsetSec in systemd, or a sleep at the top of the script. Then widen your monitoring window to match, so the jitter does not read as a missed run.
Sources: systemd.timer(5) manual, crontab(5) manual, GitHub Actions events that trigger workflows.