Cron vs systemd Timers: Measuring and Spreading Schedule Jitter on Small VPS Hosts

“Cron runs at the top of the minute” is the standard explanation for thundering-herd load spikes and missed maintenance windows, but on modern systemd hosts the real behaviour is subtler. Both scheduling mechanisms have drift, jitter, and catch-up semantics that are configurable — and misconfiguring them turns a harmless nightly backup into a pile-up of every job on the fleet firing within the same second. This article covers the actual timing model of cron versus systemd.timer, how to measure the jitter you are getting, and how to spread periodic jobs without breaking their correctness guarantees.

Two Different Timing Models

Classic cron is a minute-granularity scheduler: it wakes each minute and runs every job whose five-field expression matches. It has no built-in randomised delay, no per-job jitter, and no native catch-up for missed runs — if the machine was down or suspended at the matching minute, the job simply does not run until the next match. On a fleet of identically provisioned instances, every host’s cron daemon wakes at the same wall-clock boundary, so 0 3 * * * jobs all fire at 03:00:00 together.

systemd.timer units are event-driven and expose several timing modifiers that cron lacks:

  • OnCalendar= — real-time schedule with second granularity, supporting expressions like *-*-* 03:00:00.
  • RandomizedDelaySec= — maximum random delay added at each trigger, the native fix for fleet-wide pile-ups.
  • Persistent=true — if a run was missed while the machine was off, run it once on next boot (catch-up).
  • AccuracySec= — how much the timer may defer a trigger to batch wakeups (default 1 minute).

Measuring Actual Jitter and Drift

Before tuning, measure. Log the real start times your jobs are getting and diff them against the intended schedule:

# systemd: when did a timer last run, and when will it next?
systemctl list-timers --all
systemctl list-timers --all --no-pager | grep -i backup

# Inspect one timer's resolved schedule and its monotonic info
systemctl show myservice.timer -p NextElapseUSecRealtime -p LastTriggerUSec

# cron: confirm daemon cadence in the journal
journalctl -u cron --since "1 day ago" | grep CRON

Compare the logged start second across hosts. If ten instances all report 03:00:00, you have the thundering-herd problem. If they scatter across 03:00:00–03:02:00, existing jitter is already spreading the load.

Spreading Load: RandomizedDelaySec and Persistent Together

The canonical modern pattern is a timer with a fixed calendar anchor, a random delay sized to the acceptable spread, and catch-up enabled:

[Unit]
Description=Nightly database backup

[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=30m
Persistent=true
AccuracySec=1s
Unit=dbbackup.service

[Install]
WantedBy=timers.target

RandomizedDelaySec=30m scatters triggers across a 30-minute window from the 03:00 anchor, which collapses peak concurrent load without changing the schedule’s guarantee that the job runs once per night. Set it large enough to matter (15–60 minutes for fleet jobs) but never so large it pushes the job into a busier window. Persistent=true restores the cron feature people miss most — a missed run executing once on the next boot instead of being silently skipped.

Getting Jitter in cron Without Abandoning It

If you must stay on cron, add randomised sleep inside the job wrapper. This preserves the minute-level schedule while de-correlating execution:

# /etc/cron.d/nightly-backup — spread start over up to 30 minutes (1800s)
SHELL=/bin/bash
0 3 * * * root sleep $((RANDOM \% 1800)) && /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

The sleep approach costs one idle process per host but requires no timer units and works on any cron implementation. For truly bursty work, combine the random sleep with a lock (flock) so overlapping runs cannot corrupt state, and consider staggering by host index rather than pure random if you want a deterministic spread:

# Deterministic stagger using a stable host identifier
# Hosts with different hostnames map to different offsets in a 1800s window
OFFSET=$(( $(hostname | cksum | cut -d' ' -f1) % 1800 ))
sleep "$OFFSET"
/usr/local/bin/backup.sh

Common Jitter Pitfalls

  • AccuracySec too large with tight schedules. The default 1-minute accuracy lets systemd defer a trigger; on a 1-minute schedule this can merge or skip runs. Set AccuracySec=1s for tight cadences.
  • Persistent with short intervals. A 5-minute timer with Persistent=true runs only once after boot even if hours were missed — do not assume it replays every missed interval.
  • Timezone drift. Both systems schedule in local time; a host with the wrong TZ fires jobs at the wrong wall-clock hour. Pin with OnCalendar=... UTC for fleet consistency.
  • Overlapping runs. Randomised start times can cause a slow job to overlap the next scheduled run; always wrap mutating jobs in a lock.

Choosing Per Job, Not Per Host

Backups, log rotation, certificate renewals, and monitoring polls have different tolerance for drift and different consequences of overlap. Schedule watchful jobs (health checks) at tight, jittered intervals with AccuracySec=1s and no Persistent; schedule heavy maintenance jobs with a fixed anchor, generous RandomizedDelaySec, and Persistent=true. Document each timer’s intent so a future operator does not “fix” the random delay that is holding the fleet load smooth.

When you provision fleets of small instances for this kind of periodic work, timing behaviour is as important as CPU and RAM — the core VPS configuration guides cover sizing instances for scheduled and burst workloads, and our managed versus self-managed hosting comparison explains where automated scheduling is handled for you rather than tuned by hand.

Leave a Reply