"Run the sync every five minutes" sounds like a complete specification. It isn't. It leaves out the question that decides how your system behaves on a bad day: five minutes from when?
- From the start of the previous run?
- From the end of the previous run?
- On the clock: 10:00, 10:05, 10:10, no matter what happened before?
Those are three different scheduling models: fixed rate, fixed delay and wall-clock (cron). Most libraries offer at least two of them, often with confusingly similar names. Here's how they differ, with examples, and how to choose.
Fixed rate: every N from the start
The next run is scheduled N after the previous run started. The idea is a steady cadence.
Java's ScheduledExecutorService makes the choice explicit:
ScheduledExecutorService ex = Executors.newSingleThreadScheduledExecutor(); ex.scheduleAtFixedRate(this::sync, 0, 5, TimeUnit.MINUTES); When runs are fast: starts at 10:00:00, 10:05:00, 10:10:00. Lovely.
When a run takes 7 minutes: this is where it gets interesting. The Java documentation says that if an execution takes longer than its period, subsequent executions may start late, but will not run concurrently. So the next run starts immediately after the slow one finishes, and the scheduler tries to "catch up" towards the original cadence. A sequence of slow runs produces back-to-back executions with no breathing room.
Good for: sampling and metrics, where a steady rhythm matters and runs are reliably short.
Risk: catch-up bursts after a slow period or a pause (a laptop sleeping, a GC pause, a busy host).
Fixed delay: N after the previous run ends
The next run is scheduled N after the previous run finished.
ex.scheduleWithFixedDelay(this::sync, 0, 5, TimeUnit.MINUTES); Or the hand-rolled version every language has:
import time while True: sync() time.sleep(300) When runs are fast: 10:00:00, 10:05:02, 10:10:04... it drifts by the run duration every time.
When a run takes 7 minutes: next run at 10:12, then the one after at 10:17 or later. No overlap, no catch-up, guaranteed rest between runs.
Good for: polling and sync jobs where what matters is "not too often" and "never overlapping", not hitting exact times.
Risk: drift. "Every five minutes" quietly becomes "every five minutes plus however long it took". Over a day that adds up, and the job will never run at predictable times.
Wall-clock (cron): at fixed times
The schedule is defined against the clock: */5 * * * * means minute 0, 5, 10... of every hour.
When runs are fast: 10:00, 10:05, 10:10. Predictable, aligned, easy to reason about in logs.
When a run takes 7 minutes: classic cron doesn't care. It starts the 10:05 run while the 10:00 one is still going. Now you have two copies running at once, competing for the same rows, the same API quota, the same lock. If runs keep getting slower, copies pile up.
Many cron-like schedulers let you choose the behaviour. Kubernetes CronJob has concurrencyPolicy: Allow | Forbid | Replace. With plain cron, you add a lock yourself:
*/5 * * * * flock -n /tmp/sync.lock /usr/local/bin/sync flock -n exits immediately if the lock is held, so an overlapping run is skipped instead of stacked.
Good for: jobs tied to human time (reports at 09:00, end-of-day batches), and jobs that need to line up with other systems.
Risk: overlap by default, and the "everyone runs at :00" thundering herd if many jobs use round times.
Side by side
Here's what happens in each model when one run takes 7 minutes in a 5-minute schedule:
| Fixed rate | Fixed delay | Cron (no lock) | Cron + skip-if-running | |
|---|---|---|---|---|
| Runs overlap? | No (most libraries) | No | Yes | No |
| Next start after slow run | Immediately | 5 min after it ends | On schedule, in parallel | Next free slot |
| Drift over time | Low (catches up) | Accumulates | None | None |
| Catch-up bursts | Possible | No | No | No |
| Predictable start times | Mostly | No | Yes | Yes (when not skipped) |
Which one should you use?
Use fixed delay for "keep this in sync" jobs: polling an API, draining a table, refreshing a cache. You want a guaranteed gap and no overlap more than you want exact timing.
Use cron with a skip-if-running guard for anything humans or other systems expect at a specific time. Make the overlap policy explicit. Don't rely on "it usually finishes in time".
Use fixed rate only when the work is short and the rhythm itself matters, and make sure catch-up bursts are harmless.
Questions to ask before you pick
- What happens if two copies run at once? If the answer is anything other than "nothing", you need fixed delay or a lock.
- What happens if a run is skipped? If skipping is fine, "skip if running" is a great default. If every interval matters (billing periods, for example), you need to process the missed window explicitly rather than rely on timing.
- Does anyone care what time it runs? If not, fixed delay is the simplest correct choice.
- What happens after downtime? Does the scheduler run once, run every missed occurrence, or run nothing? Each library has a different answer, and it's worth reading the docs for yours.
The real lesson
"Every five minutes" is a requirement. Fixed rate, fixed delay and cron are implementations, and they disagree about what to do when reality doesn't fit the schedule. Pick one on purpose, write the choice next to the job, and test it with a run that's slower than the interval.
When we designed scheduling at delay.run, most of the hard questions were exactly these: overlap, drift and what to do about missed runs. They're worth answering before your job answers them for you.
Related reading
- How late is 'every minute'? An experiment you can run on cron, systemd timers and sleep loops
- The server was down at 02:00. Should the 02:00 job run late, or not at all?
- Don't use setTimeout for anything that matters: where delayed jobs should actually live
Which model do your recurring jobs use, and did anyone choose it deliberately? Have you been bitten by overlap or drift?