Cron Jitter: Why Not to Run Every Cron Job at Midnight
Contents
Don't schedule every cron job at midnight, because every job set to a round time, on every host, starts in the same minute and hits the same database, storage, and APIs at once; give each job or host its own start time instead. There are two ways to do it: fixed offsets (a deliberate odd minute per job, a per-host minute derived from the hostname, Jenkins H, systemd's FixedRandomDelay=) and random delays (a sleep before the job, cronie's RANDOM_DELAY, systemd's RandomizedDelaySec=, EventBridge Scheduler's flexible time windows). Prefer fixed offsets where you can choose. They spread load as well as random ones, and each run's start time stays predictable, which keeps ordering, deadlines, and monitoring simple.
Behavior below comes from source code and official documentation checked October 25, 2026: cronie 1.7.2, Debian's cron 3.0pl1-197, BusyBox 1.37.0, systemd 262, Jenkins 2.587, and Kubernetes 1.37.
Round Times Turn Many Small Jobs Into One Spike#
The defaults all point at the same minute. In cronie, @daily is 0 0 * * * and @hourly is 0 * * * * (crontab(5)), and systemd's daily is *-*-* 00:00:00 (systemd.time(7)). Jenkins puts the result plainly in its schedule help: "using 0 0 * * * for a dozen daily jobs will cause a large spike at midnight" (help-spec.jelly).
A fleet multiplies it. One crontab line deployed to 200 hosts fires in the same minute on all of them, so a shared database sees 200 connections, an object store 200 uploads, and a third-party API 200 clients that share one per-account rate limit. Each job is small; the spike is their sum.
The services on the receiving end ask for spreading:
- Let's Encrypt asks clients to "run at a randomized second during the day, rather than always running at a specific time", so it "doesn't receive arbitrary spikes of traffic at the top of the hour or minute" (integration guide).
- GitHub Actions says scheduled workflows "can be delayed during periods of high loads", that "high load times include the start of every hour", and that under enough load "some queued jobs may be dropped" (schedule event, checked October 25, 2026).
- AWS: "we consider adding some jitter to all timers, periodic jobs, and other delayed work" (Timeouts, retries, and backoff with jitter, Amazon Builders' Library).
Fixed Offsets or Random Delays#
| Technique | When the start time changes | Spreads | Monitor schedule |
|---|---|---|---|
| Deliberate minute per job | Never | Your own jobs | The exact time |
| Minute derived from the hostname | When the hostname changes | Hosts | Exact, per host |
Jenkins H |
When the job is renamed or moved | Jobs | The exact time |
systemd FixedRandomDelay= |
When the machine ID changes | Hosts and timers | Exact, per host |
cronie RANDOM_DELAY |
When crond restarts | Hosts | Earliest start + window |
cronie ~ ranges |
When the crontab is re-read | Jobs and hosts | Earliest start + window |
sleep with a random number |
Every run | Hosts and runs | Earliest start + window |
systemd RandomizedDelaySec= alone |
Every run | Hosts and timers | Earliest start + window |
| EventBridge flexible window, Vercel Hobby timing | Every run (platform decides) | Platform customers | Earliest start + window |
Random delays suit starts nobody can coordinate, such as a laptop fleet or a platform's customers.
Pick Deliberate Minutes for Your Own Jobs#
Write 17 3 * * * instead of 0 3 * * *, and give each of your jobs a different minute. For a job every 15 minutes, 7-59/15 * * * * runs at :07, :22, :37, and :52. Don't shorten it to 7/15: cronie 1.7.2's parser accepts only a range or * before a step (entry.c).
To see where your own jobs cluster, count system crontab entries that name a single minute, per hour and minute:
grep -hv '^[[:space:]]*#' /etc/crontab /etc/cron.d/* 2>/dev/null |
awk 'NF >= 6 && $1 ~ /^[0-9]+$/ { print $2 ":" $1 }' | sort | uniq -c | sort -rn | headMoving jobs off midnight usually moves them into the small hours. If the scheduler runs on local time, keep them, including any random delay, out of 01:00 to 03:00 local, where daylight saving time skips or repeats an hour in the US and Europe. A scheduler on UTC has no such hour.
Derive Each Host's Minute From Its Name#
When the same job runs on many hosts, hash the hostname into a minute when you install the crontab. Every host gets a different, stable minute, and you can print it:
#!/bin/sh
# install-nightly-report.sh: install the nightly report at a stable minute for this host.
crc=$(uname -n | cksum | cut -d ' ' -f 1)
minute=$((crc % 60))
printf '%s 3 * * * root /opt/nightly-report.sh\n' "$minute" > /etc/cron.d/nightly-report
echo "nightly-report runs at 03:$(printf '%02d' "$minute") on $(uname -n)"/etc/cron.d with a user field is cronie and Debian cron syntax; BusyBox crond reads only per-user crontabs. Ansible has the same idea built in: {{ 60 | ansible.builtin.random(seed=inventory_hostname) }} picks a minute that is "repeatable for each host" (random filter). Either way, hosts with the same name get the same minute, so check that cloned machines were renamed.
Add a Random Sleep in a Script, Not Inline#
The common one-liner sleeps a random number of seconds before the job. In a crontab it needs two changes:
SHELL=/bin/bash
0 3 * * * sleep $((RANDOM \% 900)) && /opt/nightly-report.shCron treats % specially: "A '%' character in the command, unless escaped with a backslash (\), will be changed into newline characters, and all data after the first % will be sent to the command as standard input" (crontab(5)). Cronie and Debian's cron remove the backslash before running the command (cronie, Debian). BusyBox crond passes the command to the shell exactly as written (crond.c), so there the backslash isn't removed by cron.
SHELL=/bin/bash matters as much. RANDOM is a bash variable, "a random integer between 0 and 32767" (Bash 5.3 manual). Cron runs commands with /bin/sh, which is dash on Debian and Ubuntu. Dash has no RANDOM and reads an unset variable in arithmetic as 0 (dash 0.5.12 var.c), so without the SHELL line every host sleeps 0 seconds and the job runs at 03:00 everywhere, with no error.
A short script avoids both traps and works under any POSIX shell:
#!/bin/sh
# /usr/local/bin/random-delay: sleep 0 to MAX-1 seconds, then run the command.
# Usage: random-delay MAX command [args...]
max=$1
shift
n=$(od -A n -N 2 -t u2 /dev/urandom | tr -d ' ')
sleep $((n % max))
exec "$@"0 3 * * * /usr/local/bin/random-delay 900 /opt/nightly-report.shOn GNU coreutils, shuf -i 0-899 -n 1 gives the same number (shuf(1)). Avoid awk 'BEGIN { srand(); print int(rand() * 900) }': with no argument, srand uses "the seconds since the Epoch" as its seed (POSIX awk), so every host started in the same second computes the same delay.
cronie's RANDOM_DELAY and Random Ranges#
Cronie, the cron on Fedora, RHEL and its rebuilds, and Arch, has two built-in options. Debian's cron and BusyBox crond have neither; Debian passes RANDOM_DELAY to the job as an ordinary variable.
RANDOM_DELAY (since cronie 1.4.11, ChangeLog) delays the jobs below it "by random amount of minutes with upper limit specified by the variable":
RANDOM_DELAY=20
0 3 * * * /opt/nightly-report.sh
30 4 * * * /opt/cleanup.shThe randomness is per daemon, not per job or run: "The random scaling factor is determined during the cron daemon startup so it remains constant for the whole run time of the daemon" (crontab(5)). Each job's delay is its RANDOM_DELAY times that factor, rounded down to whole minutes, with 1,440 as the maximum value (entry.c). So both jobs above move by the same number of minutes, between 0 and 19, until crond restarts. That spreads hosts apart, not one host's jobs; crond logs the factor when it starts (cron.c). To apply it to every crontab on a host, set it in /etc/sysconfig/crond, which cronie's systemd unit loads into crond's environment (cronie.systemd) and crond copies into each crontab (env.c).
The ~ operator (cronie 1.6.0, NEWS) picks a random value in a range: 0~29 3 * * * runs once at some minute from 03:00 to 03:29. "The random number is picked when crontab file is parsed", so it changes when crond restarts or the file changes. Enterprise Linux 9 still ships cronie 1.5.7, without it, and Enterprise Linux 10 ships 1.7.0 (Rocky Linux 9, Rocky Linux 10).
Cronie's anacron reads its own RANDOM_DELAY in /etc/anacrontab: the maximum number of minutes added to each job's delay (anacrontab(5)), 45 in the default file (anacrontab).
systemd Timers: RandomizedDelaySec, FixedRandomDelay, RandomizedOffsetSec#
Three [Timer] settings spread starts (systemd.timer(5), systemd 262):
RandomizedDelaySec=(systemd 229) delays each run by "a randomly selected, evenly distributed amount of time between 0 and the specified time value", chosen again before each run. The delay counts from "the next determined elapsing time or the service manager's startup time, whichever is later".FixedRandomDelay=yes(systemd 247) keeps that delay the same across runs and restarts. It is "derived from the machine ID, the manager's user identifier, and the timer unit's name", so machines cloned from an image that kept/etc/machine-id, which machine-id(5) says shared images shouldn't, all get the same delay.RandomizedOffsetSec=(systemd 258) adds a stable offset toOnCalendar=timers with "no regard to manager startup time", so a long offset still fires on machines that reboot often, where a longRandomizedDelaySec=restarts at every boot and may never finish. Ubuntu 24.04 (systemd 255) and Debian 13 (257) predate it; older systemd ignores an unknown setting with a warning.
AccuracySec= (default one minute) works the other way, shifting runs to batch wake-ups. For an even spread, the man page says to "set AccuracySec=1us and RandomizedDelaySec= to some higher value."
# /etc/systemd/system/nightly-report.timer
[Unit]
Description=Nightly report, at a stable point in 15 minutes per host
[Timer]
OnCalendar=*-*-* 03:00:00 UTC
RandomizedDelaySec=15min
FixedRandomDelay=yes
AccuracySec=1us
Persistent=true
[Install]
WantedBy=timers.target# /etc/systemd/system/nightly-report.service
[Unit]
Description=Nightly report
[Service]
Type=oneshot
ExecStart=/opt/nightly-report.shEnable it with systemctl enable --now nightly-report.timer. systemctl list-timers nightly-report.timer shows the next run with the delay already added, because systemd adds it to the next elapse time before publishing it (timer.c). Debian's own apt-daily.timer (apt 3.0.3) uses the same setting, running at 06:00 and 18:00 with RandomizedDelaySec=12h (apt-daily.timer). systemd timers covers the rest of the timer options.
Jenkins H Hashes the Job Name#
Jenkins cron accepts H in any field: H H * * * "would still execute each job once a day, but not all at the same time". H "can be thought of as a random value over a range, but it actually is a hash of the job name, not a random function, so that the value remains stable for any given project" (help-spec.jelly, also in the Pipeline syntax docs):
| Spec | Runs |
|---|---|
H H(0-7) * * * |
Once a day, at a fixed time between 00:00 and 07:59 |
H/15 * * * * |
Every 15 minutes, "perhaps at :07, :22, :37, :52" |
@midnight |
Once a day, "some time between 12:00 AM and 2:59 AM" |
@hourly |
Same as H * * * * |
The hash input is the job's full name, folder path included (Trigger.java), so renaming a job or moving it into a folder moves its run time. Jenkins shows the result under the schedule field: "Would last have run at …; would next run at …" (Messages.properties). Jenkins cron covers the rest of the trigger syntax and why a scheduled build silently never runs.
Kubernetes, GitHub Actions, and Managed Schedulers#
- Kubernetes CronJob has no jitter setting: none of the eight fields in the 1.37
CronJobSpecadds a delay (types.go). Give each CronJob its own minute. Asleepin the container holds the Pod and its resource requests while it waits, and the wait counts againstactiveDeadlineSeconds, which runs from the Job's start (types.go). - GitHub Actions has no delay setting either, and GitHub's advice is to "schedule your workflow to run at a different time of the hour":
17 3 * * *rather than0 3 * * *. - EventBridge Scheduler spreads invocations for you with
"FlexibleTimeWindow": {"Mode": "FLEXIBLE", "MaximumWindowInMinutes": 15}, which invokes the target within 15 minutes after the scheduled time (flexible time windows); the window can be 1 to 1,440 minutes (FlexibleTimeWindow). EventBridge Scheduler covers the rest. - Vercel Hobby already jitters: Vercel "may invoke these cron jobs at any point within the specified hour to help distribute load across all accounts" (managing cron jobs, checked October 25, 2026).
A Spread Start Doesn't Fix a Job That Floods an API#
Jitter spreads when jobs begin. A job that then sends 10,000 requests as fast as it can still hits the rate limit, just at 03:07. Inside the job, cap concurrency, back off on HTTP 429 and honor Retry-After, and randomize retries too: "If all the failed calls back off to the same time, they cause contention or overload again when they are retried" (Amazon Builders' Library). In shell jobs, curl's --retry treats timeouts and HTTP 408, 429, 500, 502, 503, 504, 522, and 524 as transient, doubles its wait from one second, and follows Retry-After since curl 7.66.0 (curl manual); the wait isn't randomized, so the spread start has to supply the jitter. --retry-max-time caps the total so retries end inside the job's own timeout.
When Not to Add Jitter#
- Hard deadlines. If a report must be ready by 06:00, the earliest start plus the largest delay plus the longest run must fit before then. Flexible windows and random delays only move runs later.
- Ordered steps. If the 03:30 job assumes the 03:00 job finished, a random delay can reverse them, as can one long run. Make the second step part of the first job's script, or start it from the first job's success, instead of relying on two clock times.
- Jobs that should run once. If every host runs the same cleanup against the same database, staggering it turns one burst into 200 redundant runs. Run it on one node instead, with the options in distributed cron.
Monitoring a Job Whose Start Time Moves#
In CronRadar, every check-in moves the expected time to the schedule's next occurrence after the check-in arrives. That makes the rule for a jittered job simple: the monitor's schedule is the earliest possible start, and the grace period covers the whole window.
Suppose a job runs at 03:00 plus up to 15 minutes, and you set the monitor to 03:07, the middle of the window. A run that checks in at 03:02 moves the expected time to the next occurrence after 03:02, which is 03:07 the same day. No second run comes, so the monitor alerts at 03:07 plus the grace period. With 0 3 * * *, every check-in between 03:00 and 03:15 moves the expectation to 03:00 tomorrow.
There is no CronRadar SDK for cron or systemd and no auto-discovery, so each job reports with its own HTTP check-in. On a host whose clock is UTC, this wrapper reports after the random delay, so the recorded duration excludes the sleep and the grace covers the 15-minute window plus headroom:
#!/bin/sh
# /opt/nightly-report-monitored.sh, started by random-delay after its sleep.
# The key lives in a file only the job's account can read, not in the crontab.
CRONRADAR_API_KEY=$(cat /etc/nightly-report/cronradar.key 2>/dev/null)
url="https://cron.life/api/v1/ping/nightly-report-$(uname -n)"
run_id="nightly-report-$(date +%s)-$$"
report() {
event=$1
shift
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=0 3 * * *" \
--data-urlencode "gracePeriod=1200" \
--data-urlencode "run_id=$run_id" \
"$@" "$url/$event" || true
}
report start
/opt/nightly-report.sh
rc=$?
if [ "$rc" -eq 0 ]; then
report complete
else
report fail --data-urlencode "message=nightly-report.sh exited with code $rc"
fi
exit "$rc"0 3 * * * /usr/local/bin/random-delay 900 /opt/nightly-report-monitored.shEach report gives up after five seconds and ignores errors, and the script exits with the job's own status. The key stays out of the crontab because cronie and Debian's cron copy crontab variables into the headers of the mail they send (cron security).
- Expect
warningduring the window. Once 03:00 passes without a check-in, the monitor can showwarninguntil the run'sstartarrives. That's a status, not an alert; it turnscriticalonly after the grace period. - Fixed offsets get exact schedules. For a hostname-derived minute, a Jenkins
Htime, or aFixedRandomDelay=time read fromsystemctl list-timers, send that time, in UTC, as the schedule and size the grace for ordinary start delay, as in choosing a grace period. - Moved offsets need a settings change. A check-in's
scheduleandgracePeriodapply only when it creates the monitor, so after a Jenkins rename, a new machine ID, or a wider delay, edit the monitor's settings in the dashboard. - One monitor per host or one per fleet. The monitor key above includes the hostname, so each host has its own monitor. One monitor key shared by every host tells you only that at least one host ran, because any host's check-in satisfies the expected run; a
failfrom any host still alerts at once. - The spread protects check-ins too. Telemetry is limited to 60 requests a minute and 1,000 an hour per API key (API reference), and a start-and-complete run sends two. A hundred hosts on one application key firing in the same minute exceed it; the wrapper ignores the 429, so rejected runs can look missed. Spread over 15 minutes, the same fleet sends about 13 requests a minute.
Frequently Asked Questions#
Why shouldn't I schedule every cron job at midnight?#
Every job at 00:00, on every host, starts in the same minute and loads the same database, storage, and APIs at once, often into a rate limit they share. Let's Encrypt asks clients for randomized times, and GitHub Actions warns that the start of every hour is a high-load time when scheduled runs are delayed or dropped. Give each job its own minute, and each host its own offset.
How do I add a random delay to a cron job?#
Run the job through a small script that sleeps a random number of seconds from /dev/urandom, then runs it. Writing sleep $((RANDOM % 900)) directly in a crontab needs \% instead of %, and SHELL=/bin/bash, because /bin/sh on Debian and Ubuntu has no RANDOM and silently sleeps zero seconds. On cronie, RANDOM_DELAY=20 delays the jobs below it by up to 20 minutes, one fixed amount per host until crond restarts.
What does RandomizedDelaySec do?#
It delays each run of a systemd timer by a random amount between zero and its value, picked again before every run (systemd 229 and later). Add FixedRandomDelay=yes (systemd 247) to keep one stable delay per machine and timer, and AccuracySec=1us so systemd doesn't shift the run again to batch wake-ups.
What does H mean in Jenkins cron?#
H is a hash of the job's full name, mapped into the field's range. H H * * * runs each job once a day at a time that differs between jobs but stays the same for a given job, and H/15 runs every 15 minutes at a job-specific offset. Renaming or moving the job changes its time.
How do I monitor a job with a random start time?#
Set the monitor's schedule to the earliest time the job can start and a grace period covering the full delay window plus normal start delay. In CronRadar, a check-in moves the expected time to the schedule's next occurrence, so a schedule in the middle of the window can expect a second run that never comes. Jobs with fixed offsets can use their exact time instead.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.