The tick arrives before the last run finished
Cron fires on a clock, not on completion. You schedule a job every five minutes because it normally takes thirty seconds, and for months that holds. Then one night a database is slow, or an upstream API hangs, and the run that started at 02:00 is still going at 02:05 when the next one comes due.
Now there is a decision to make, and you probably never made it. What happens next was settled by whichever scheduler you use and its defaults. A recent write-up, Job queues are deceptively tricky (July 2026), put it plainly: when a run overruns its interval, the scheduler has to choose, and the right choice depends on what the job actually does.
Four things a scheduler can do
There are only four answers to "the last run is still going and it is time to start again." Every scheduler picks one.
Run both at once
Start the new run anyway. Two copies now execute in parallel. That is fine for stateless, read-only work and a disaster for anything that writes, where you end up with two backups clobbering one file or two exporters double-charging.
Skip the new run
Leave the old run alone and drop the one that just came due. The schedule thins out under load. Safe for idempotent maintenance where a missed tick costs nothing. Dangerous when every tick is meant to process a distinct batch, because a skipped batch never comes back on its own.
Replace the old run
Kill the run in progress and start fresh. Good for jobs where only the latest result matters, like a cache warm or a search reindex that was going to throw the old run away anyway. Wrong for a job that has to finish what it started.
Queue the new run
Let the old run finish, then start the queued one right after. Nothing overlaps and nothing is lost. But the queue grows every time the job runs slower than its interval, and you drift further behind wall-clock time with each cycle.
What each scheduler actually does by default
Most people never see that list. They inherit a default.
Plain cron runs both
Vixie-style cron keeps no memory of the previous run. Every matching minute it forks the command, full stop. If the job overruns you get overlap, silently, and it keeps happening until the slow condition clears or the box runs out of memory.
flock turns cron into skip
The usual fix wraps the command so it takes a lock and gives up if it cannot get one:
*/5 * * * * /usr/bin/flock -n /tmp/export.lock /opt/app/export.sh
The -n flag is non-blocking. If the previous run still holds the lock, flock exits at once and that tick is skipped. Drop the -n and you get queue behaviour instead: the new invocation waits on the lock and runs once the old one lets go. One flag, two completely different policies.
systemd timers skip on their own
A timer activates a service unit. If that unit is still active when the timer next elapses, systemd does not start a second copy. The man page is blunt about it: "if the unit to activate is already active at the time the timer elapses it is not restarted, but simply left running." So a .timer hands you skip semantics with no lock file, whether or not that is what you wanted.
Kubernetes makes you choose
A CronJob carries a concurrencyPolicy field. Allow, the default, permits concurrent Jobs. Forbid skips the new run while the previous one is unfinished. Replace cancels the running Job and starts a new one in its place.
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
This is the honest design: the choice sits in the spec, next to the schedule, instead of hiding inside the tool's behaviour. One trap: concurrencyPolicy only governs Jobs from the same CronJob. It says nothing about two pods inside one Job, or the same schedule running in two clusters.
Match the policy to the fault model
That same write-up used a git repack that takes seven hours, scheduled every three, on a weekend when nobody is watching. Run both and the repacks stack up until the host falls over. Queue them and you never catch up. The policy that fits here assumes the job can overrun and keeps a fresh, complete run rather than a stale, half-finished one.
So the real work is writing down what the job costs when it doubles up, when it gets skipped, and when it gets killed mid-run. A backup must never be interrupted, so Replace is wrong for it. A batch importer has to handle every batch, so Forbid quietly loses data. The answer changes per job, and you cannot read it off the schedule.
Overlap is a monitoring blind spot
Here is the part that bites even teams who set the policy correctly. A skipped run produces nothing. No new process, no exit code, no log line announcing that it declined to start. From the outside, a Forbid skip and a flock skip look the same as a clean success, and the same as a run that never fired. The dashboard stays green because the previous run is, technically, still running.
That is the gap a dead man's switch closes. Rather than waiting for a failure signal that a skip never sends, you watch for a completion signal that should arrive and does not. The job pings a URL only when it finishes; if a tick gets skipped and nothing completes inside the window, the check-in goes missing and you hear about it. The lock or the policy decides whether the job runs. The heartbeat tells you whether it finished.
Watch duration too. A job whose usual thirty seconds has crept to four minutes is heading for its own interval, and that is where the skips and pile-ups begin. Trend it and you see the collision coming.
Frequently asked questions about overlapping scheduled jobs
What happens if a cron job takes longer than its interval? Plain cron starts the next run anyway, so you get two or more copies executing at once. Cron keeps no record of the previous run and forks the command at every matching minute regardless of what is already running. Overlap continues until the slow condition clears, which is why long-running jobs need a lock or a scheduler that understands concurrency.
How do I stop two copies of the same cron job from overlapping? Wrap the command in flock with the non-blocking flag, so a run that cannot get the lock exits immediately and that tick is skipped. systemd timers already skip by leaving the running unit alone, and a Kubernetes CronJob does it when you set concurrencyPolicy to Forbid. Which of these you use matters less than making the choice deliberately.
What is the difference between Forbid and Replace in a Kubernetes CronJob? Forbid skips the new run and lets the current Job keep going, so the job in progress always finishes. Replace cancels the currently running Job and starts a fresh one in its place. Use Forbid when a run must complete, and Replace when only the newest result matters and an interrupted old run is safe to discard.
Will my monitoring notice a skipped run? Usually not. A skipped run creates no process and no exit code, so it looks identical to a clean success and to a run that never started. Dead man's switch monitoring catches it because it alerts on the absence of a completion signal within the expected window, rather than waiting for a failure that a skip never produces.
Does flock queue the skipped run or drop it? It depends on the flag. With the non-blocking flag the run is dropped and that tick is lost. Without it, flock blocks until the lock is free and then runs, which turns the missed tick into a queued run that executes late. Choose based on whether a delayed run is more useful than a dropped one for that job.
Further reading
- Preventing Duplicate Cron Jobs When You Scale to Multiple Servers
- Everything Runs at Midnight: Spreading Out Your Scheduled Jobs
- The Kubernetes CronJob Deadlock That Stops Your Schedule For Good
Conclusion: A job that outruns its own interval is not an edge case. It is what every scheduled job does eventually, the first time a dependency slows down. The scheduler will resolve the collision one way or another, and if you never chose the policy then you inherited it. Decide per job whether overlap, a skip, a replace, or a queue does the least damage, set that policy where the tool lets you, and monitor for the absence of a completed run so a quiet skip never reads as success.
Sources: Job queues are deceptively tricky, Kubernetes CronJob documentation, systemd.timer(5) manual, flock(1) manual.