Cron Offset
Cron has no offset operator. A step like `*/15` always begins counting at its field's minimum value, which is why almost every job on almost every server fires on the quarter hour, and why minute :00 is the busiest sixty seconds in most infrastructures. Shifting a schedule onto a different phase is possible, but it is done by changing the range the step counts over — not by adding an offset. This guide covers that technique, the fleet-wide staggering problem it solves, and the two other things people mean when they say "cron offset."
Updated
The rule: steps anchor at the field minimum
There is no offset operator in cron. There is only one rule about steps, and every technique on this page follows from it:
*/nmeans “start at the minimum value of this field, then keep addingnwhile the result is still in range.”
The * is not decoration. It stands for the field’s entire range — 0-59 for minutes, 0-23 for hours, 1-31 for day-of-month — and the step counts from the start of whatever range precedes it. Since * always starts at the minimum, a bare */n always fires at the minimum.
That single rule explains a family of behaviors that otherwise look arbitrary:
| Expression | Fires at | Because |
|---|---|---|
*/15 * * * * | :00, :15, :30, :45 | minute field starts at 0 |
0 */2 * * * | 00:00, 02:00, … 22:00 | hour field starts at 0, so always even |
0 0 */14 * * | 1st, 15th, 29th | day-of-month starts at 1, not 0 |
That last row is worth a second look — day-of-month is the one common field whose minimum isn’t zero, which is why every other week fails in a way that surprises people who have internalised the rule for minutes and hours.
Shifting a schedule onto a different phase
To move the phase, replace the * with an explicit range whose lower bound is where you want counting to begin:
*/15 * * * * # :00, :15, :30, :45
5-59/15 * * * * # :05, :20, :35, :50
7-59/15 * * * * # :07, :22, :37, :52
The lower bound is the offset. The upper bound only needs to be large enough to contain the last value you want — 5-59/15 and 5-50/15 produce the same four minutes — but write the real bound anyway, because the intent reads more clearly and the expression survives someone changing the step later.
The same idea works in any field. In hours, 0 1-23/2 * * * shifts a two-hourly job onto odd hours instead of even ones.
An explicit list is always equivalent and often more readable:
5,20,35,50 * * * *
There is no performance argument between the two forms — every cron implementation expands both to the same set of matching values before it ever compares against the clock. Pick whichever your team misreads less often.
Quartz and EventBridge have a shorter form
Both support start/step, which drops the upper bound entirely:
0 5/15 * * * ? # Quartz: from minute 5, every 15th, to the end of the field
This does not exist in the Unix family — crontab, Kubernetes CronJob, GitHub Actions, Vercel and Cloudflare Workers all require the range/step form. Porting a Quartz expression to Kubernetes means rewriting 5/15 as 5-59/15. The dialect comparison lists the rest of the differences.
The :00 stampede
Here is why offsetting matters in practice rather than as trivia.
Because every step anchors at the minimum, the default schedules all coincide. At minute :00 of every hour, these fire simultaneously:
*/5 * * * * # every 5 minutes
*/10 * * * * # every 10 minutes
*/15 * * * * # every 15 minutes
*/30 * * * * # every 30 minutes
0 * * * * # hourly
And at midnight, every daily, weekly and monthly job written the obvious way joins them. Minute :00 of hour 00 is the single busiest moment on a typical server, by a wide margin, and nobody chose that — it is an emergent property of the anchoring rule plus the path of least resistance.
The symptoms are familiar: CPU spikes on the hour, database connection pools exhausted at :00 and fine at :01, a monitoring graph with a sawtooth you can set your watch by, API rate limits tripped only at the top of the hour, and backup jobs that mysteriously take longer than they did when they ran alone.
Staggering a fleet of jobs
The fix is to give each job its own starting minute while leaving its cadence alone:
3-59/15 * * * * /usr/local/bin/sync-a.sh
7-59/15 * * * * /usr/local/bin/sync-b.sh
11-59/15 * * * * /usr/local/bin/sync-c.sh
Three jobs, each still running every 15 minutes, none of them ever colliding. Nothing about the workload changed — only the phase.
Two things worth doing when you assign offsets by hand:
Use prime-ish minutes rather than round ones. Offsets of 5, 10, 15 tend to re-collide with the next person’s */5 job. Offsets of 3, 7, 11, 13, 17 are less likely to overlap with schedules you don’t control.
Write down why. An offset of 7-59/15 looks like a typo to the next engineer, and typos get “fixed.” A one-line comment above it — # offset to avoid the :00 pile-up with sync-a — is the difference between a stagger that survives and one that quietly gets normalised back to */15.
For hourly and daily jobs the same reasoning applies to the minute field:
0 2 * * * /usr/local/bin/backup.sh # everyone's 2 AM backup
17 2 * * * /usr/local/bin/backup.sh # yours, alone at 02:17
Schedulers that offset for you
Assigning offsets by hand does not scale past a few dozen jobs. Several schedulers automate it.
Jenkins adds an H (“hash”) symbol that standard cron doesn’t have. H/15 * * * * keeps a 15-minute cadence but derives the starting minute from a hash of the job name, so every job gets a different, stable offset without anyone choosing one. H H(0-7) * * * means “some consistent minute of some consistent hour between midnight and 7 AM.” The stability matters — a random offset chosen fresh each run would make the interval unpredictable, while a hashed one is fixed for the life of the job.
systemd timers offer RandomizedDelaySec=, which delays each activation by a random amount up to the value given, and AccuracySec=, which lets the kernel coalesce nearby timers to save power. Note that AccuracySec defaults to one minute, which is enough to blur any sub-minute schedule — set it to 1s if precision matters. See systemd timers.
Kubernetes CronJob has no offset or jitter feature. startingDeadlineSeconds is sometimes mistaken for one, but it controls how long a missed schedule may still be started, not when a schedule fires. Staggering CronJobs means writing different expressions per job, exactly as with crontab. See the Kubernetes CronJob guide.
GitHub Actions does not offset, and in practice does something worse: scheduled workflows are queued on a shared pool and can be delayed by tens of minutes during busy periods, with the top of the hour being the worst time to ask. Deliberately scheduling at an odd minute — 23 * * * * rather than 0 * * * * — measurably improves start latency. See the GitHub Actions guide.
If your scheduler offers nothing, the crude fallback is a sleep at the top of the script — sleep $((RANDOM % 300)) before the real work. It spreads load, but it also means the job’s actual start time is unknowable from the crontab, which makes incidents harder to reason about. Prefer a real offset in the expression.
The other offset: timezones
“Cron offset” also gets used to mean timezone offset, which is a different mechanism with different failure modes.
Changing the timezone shifts every firing of a schedule by that zone’s UTC offset, and — critically — that shift changes twice a year wherever daylight saving applies. A job pinned to America/New_York runs at 22:00 UTC in winter and 21:00 UTC in summer, from an unchanged cron expression.
- crontab —
CRON_TZ=America/New_Yorkabove the entry. - Kubernetes —
spec.timeZoneon the CronJob. - Spring — the
zoneparameter on@Scheduled. - Quartz — set on the trigger.
- AWS EventBridge and GitHub Actions — no timezone support at all. Convert to UTC by hand and revisit at each DST change.
The distinction that matters: range offsets are stable, timezone offsets are not. 5-59/15 fires at :05, :20, :35 and :50 forever. A schedule pinned to a DST-observing zone moves relative to UTC twice a year, and any downstream system that assumed a fixed UTC time will drift out of step with it. If a job coordinates with something in another timezone, put both on UTC and do the local-time conversion inside the application.
When offsetting can’t help
Offsetting changes the phase of a schedule. It cannot change the period.
If the interval doesn’t divide evenly into its field, the schedule is broken no matter where you start it. */45 * * * * fires at :00 and :45, with gaps alternating 45 and 15 minutes. Writing 5-59/45 gives you :05 and :50 — the same broken alternation, moved five minutes over.
Every interval where 60 % n != 0 behaves this way: 7, 8, 9, 11, 13, 14, 25, 35, 40, 45, 50. The divisors of 60 — 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30 — are the only values a single */n handles cleanly. See every 45 minutes and every 45 seconds for the full arithmetic and the multi-entry workaround.
The same limit applies at larger scales. Every other week cannot be offset into correctness either, because the problem isn’t the phase — it’s that the day-of-month field resets before a 14-day cycle can complete.
When you hit that wall, the honest answer is usually that you wanted an interval, not a schedule. Cron expresses positions on the clock; fixedDelay, IntervalTrigger and OnUnitActiveSec express gaps between runs. Offsetting is the right tool only when the firings genuinely need to land on particular clock positions — just not the ones everybody else is using.
Frequently asked questions
How do I offset a cron job by 5 minutes?
Why do all my cron jobs run at the same time?
Does cron have a random or hashed offset?
Is a timezone the same as an offset in cron?
Can I offset a schedule that doesn't divide evenly into its field?
Related
Every Odd Hour
`0 1-23/2 * * *` — the canonical offset, shifting a 2-hour step off even hours.
PatternEvery 15 Minutes
`*/15 * * * *` — the quarter-hour schedule everyone lands on by default.
PatternEvery 45 Minutes
Where offsetting stops working, because 45 doesn't divide 60.
ToolCron Parser
Paste an offset expression and check the next runs land where you expect.
GuideHow Cron Expressions Work
Field-by-field guide to the five fields, ranges, lists and steps.
GuideCron Dialect Comparison
Where Unix, Quartz, K8s, EventBridge, Spring and GitHub Actions diverge on step syntax.
GuideThe DOM / DOW OR Trap
The other cron behavior that does something different from what it reads like.