Missed Cron Job After Reboot: What Each Scheduler Does
Contents
Cron does not run a job it missed while the server was down: after a reboot, cronie, Debian's cron, and BusyBox crond all start from the current minute, and that run never happens. Anacron and systemd's Persistent=true run a missed job once, Kubernetes creates one Job for the latest missed time, Hangfire's Strict mode replays every occurrence, and APScheduler's defaults drop it. Which one you get depends on the scheduler and a setting or two, and a heartbeat monitor reports the missed run either way.
Each behavior below comes from source code or official documentation, checked October 24, 2026: cronie 1.7.2, Debian's cron 3.0pl1-197, BusyBox 1.37.0 and 1.38.0, Debian's anacron 2.3-43, systemd 262.1, Kubernetes 1.37.1, Quartz.NET 3.22.7 and 4.6.0, Hangfire 1.8.25, Celery 5.6.3, APScheduler 3.11.3, and node-cron 4.6.0.
Missed-Run Behavior at a Glance#
| Scheduler | Missed runs made up, by default | Setting that changes it |
|---|---|---|
| cronie, Debian cron, BusyBox | None | None |
| anacron | One per job whose period in days ended | Delay, START_HOURS_RANGE |
systemd OnCalendar= timer |
None; one with Persistent=true |
Persistent= |
| Kubernetes CronJob | One, for the most recent missed time | startingDeadlineSeconds |
| GitHub Actions | None | None |
| EventBridge Scheduler | None; retries failed invocations | Retry policy |
| Google Cloud Scheduler | None; no retries by default | retryConfig |
| Quartz.NET, persistent store | One for cron triggers | Misfire instruction |
| Hangfire | One | MisfireHandling |
| Celery beat | One, if the schedule file survived | beat_cron_starting_deadline |
| APScheduler, persistent store | None | misfire_grace_time, coalesce |
| node-cron, in-memory | None | None |
Symfony Scheduler skips runs due while no worker was running unless the schedule is stateful(); Symfony Scheduler covers what that changes. The CronRadar Scheduled Job Methodology adds Temporal, Cadence, Apache Airflow, and Control-M to this comparison and ties the choice to whether a job's runs can be skipped.
Linux Cron Starts From the Current Minute#
At startup, cronie sets its internal clock to the present and matches from there (cron.c), and Debian's cron does the same (cron.c). Neither records what last ran, so a 02:30 job on a server that was rebooting at 02:30 is skipped until tomorrow. What is a cron job covers the rest of what cron leaves to you.
Their only look-back is for a clock that moves while the daemon keeps running. Each minute, the daemon compares the clock with the minute it expected (cron.c):
- 2 to 5 minutes ahead: every job for each missed minute runs.
- 6 to 180 minutes ahead: fixed-time jobs for each missed minute run; interval jobs, with
*in the minute or hour field, run once. - Up to 180 minutes behind: only interval jobs run until the clock catches up.
- More than 3 hours either way: a clock correction; nothing from the gap runs.
The rule exists for daylight saving time (cron daylight saving time), but it can't tell why the clock moved, so a host resumed from suspend, or a VM unpaused, within three hours gets the same catch-up.
BusyBox crond, the cron in Alpine and many minimal images, also starts from the current time. After a forward jump of up to an hour, it starts each job that matched a minute in the gap, once. After a jump of more than an hour either way, it logs time disparity of N minutes detected and runs nothing for that wake-up (crond.c, unchanged from 1.37.0).
Anacron Catches Up Once, Counted in Days#
Anacron isn't a daemon; cron or a systemd timer starts it. For each job in /etc/anacrontab, it reads a file in /var/spool/anacron holding the date the job last ran. If at least the job's period in days has passed, it waits the job's delay in minutes and runs it once, however many periods were missed (anacron(8)). Cronie includes it; Debian and Ubuntu package it separately.
Three details decide when the missed run happens:
- The date is written when the job exits, whatever its exit status (runjob.c). A job killed by a shutdown runs again at the next start; one that failed waits for its next period.
- Cronie's anacron honors
START_HOURS_RANGEandRANDOM_DELAY. If the range is missed, jobs "are not executed that day" (anacrontab(5)). Fedora ships3-22and45, and/etc/cron.hourly/0anacronstarts anacron hourly unless the host is on battery (anacrontab, 0anacron), so a host that stays up runscron.dailybetween about 03:06 and 03:51. - Debian's anacron 2.3-43 has neither variable. It runs at boot and at half past each hour from 07:30 to 23:30, on AC power only (anacron.timer, anacron.service), and Debian's
/etc/crontabskips its own 06:25cron.dailyrun when anacron is installed (crontab).
systemd Timers Catch Up Only With Persistent=true#
Without Persistent=, a calendar timer behaves like cron after a reboot: the next run is the next OnCalendar= time. With Persistent=true, systemd keeps the last trigger time in a stamp file, and "the service unit is triggered immediately if it would have been triggered at least once during the time when the timer was inactive" (systemd.timer(5), systemd 212 and later). That's one run however many events passed, delayed by RandomizedDelaySec= if set.
Three edges, from systemd 262.1's man page and timer.c:
- First start: with no stamp file, systemd creates one and waits for the next event, so enabling a new persistent timer doesn't run the job at once.
- Suspend: calendar events that pass while the machine sleeps run once on resume, with or without
Persistent=. - Monotonic timers ignore
Persistent=.OnUnitActiveSec=does nothing until the service has run once, so pair it withOnBootSec=.
systemd timers covers the stamp file and why a catch-up run that starts early in boot should wait for the network.
Kubernetes CronJobs Create One Job for the Latest Missed Time#
When the CronJob controller in kube-controller-manager comes back, it looks at scheduled times after .status.lastScheduleTime and creates one Job, for the most recent; the earlier times are skipped (utils.go, cronjob_controllerv2.go). An hourly CronJob whose controller was down from 08:10 to 13:05 gets one Job, for 13:00. Unsuspending a CronJob takes the same path: the documentation says the missed Jobs are scheduled immediately, and that means one Job.
startingDeadlineSeconds limits the look-back to that many seconds before now. With 600, a controller back at 13:05 still creates the 13:00 Job; one back at 13:20 creates nothing until 14:00 and records no event for the skipped run.
The CronJob limitations section still says that past 100 missed schedules the controller "does not start the Job." That describes the controller replaced as the default in 1.21 (release blog). In 1.37.1, more than 100 missed times adds a TooManyMissedTimes warning event, and the Job is still created (utils.go).
GitHub Actions Drops Runs Without Replaying Them#
GitHub's documentation says the schedule event "can be delayed during periods of high loads," that the start of every hour is one, and that "some queued jobs may be dropped" (schedule, checked October 24, 2026). It describes nothing that replays a dropped run. For a self-hosted runner that's offline, "a job can be in the queue for 24 hours before it is automatically cancelled" (limits), so runs queued while it was away start late if it returns within a day.
EventBridge Scheduler and Cloud Scheduler Retry the Call#
Both run the schedule on the provider's side, so the outage that matters is your target's, and both can retry the one invocation instead of replaying the schedule.
- EventBridge Scheduler provides "at-least-once event delivery" (overview). When the target returns an error, it retries with exponential backoff until
MaximumRetryAttempts(0 to 185) orMaximumEventAgeInSeconds(60 seconds to 24 hours) runs out (RetryPolicy), then sends the event to an SQS dead-letter queue if you set one. A target back within that window gets the run late. Lambda targets are invoked asynchronously, so a function that throws is retried by Lambda, two more times by default (Lambda). - Google Cloud Scheduler defaults
retryCountto 0: "a job attempt won't be retried if it fails," and it waits for the next scheduled time. Up to 5 retries are allowed, backing off from 5 seconds to an hour, and "the next scheduled execution time might be skipped if the retries continue through that time" (retry jobs, checked October 24, 2026). TheX-CloudScheduler-ScheduleTimeheader keeps the original scheduled time across retries, a usable deduplication key (overview).
Neither service documents what happens to occurrences that pass while a schedule is disabled or paused.
Quartz.NET Applies Each Trigger's Misfire Instruction#
A firing is a misfire once it's more than quartz.jobStore.misfireThreshold late, 60 seconds by default (configuration). Downtime produces misfires only with a persistent store such as AdoJobStore; with RAMJobStore, "all scheduling information is lost" when the process ends (job stores). With a persistent store, the scheduler finds misfired triggers when it starts and applies each one's instruction (misfire instructions).
The default smart policy, the same in 3.22.7 and 4.6.0, fires a cron, calendar-interval, or daily-time-interval trigger once now and then resumes the schedule (CronTriggerImpl.cs). A simple trigger fires now, unless it repeats forever, in which case it skips to the next scheduled time (SimpleTriggerImpl.cs). DoNothing skips to the next time; IgnoreMisfires fires every missed occurrence, so a 15-second trigger that misfired for 5 minutes "will fire 20 times" (MisfireInstruction.cs). Quartz.NET 3.x sets these with builder methods such as WithMisfireHandlingInstructionDoNothing(); 4.6.0's CronScheduleBuilder takes WithMisfireInstruction(CronTriggerMisfireInstruction.DoNothing).
Triggers declared through AddQuartz replace the stored ones at startup, and unless ScheduleTriggerRelativeToReplacedTrigger is on (it's off by default), each replacement computes its next fire time from its own start time, so the downtime misfire is lost (SchedulingOptions.cs). Quartz.NET monitoring shows a listener that logs misfires.
Hangfire Creates One Job by Default#
The recurring job scheduler runs inside the Hangfire server, so nothing is created while no server is up. When one returns, RecurringJobOptions.MisfireHandling decides: Relaxed, the default, creates "only a single background job ... no matter how many occurrences were missed"; Strict creates one per missed occurrence, with its scheduled time in the Time parameter; Ignorable creates none (MisfireHandlingMode.cs). Hangfire recurring job not running covers where catch-up counts from and why Ignorable is stricter than it sounds.
Celery Beat Sends Each Overdue Task Once#
The default PersistentScheduler keeps each entry's last run time in the celerybeat-schedule file (periodic tasks). At startup it merges beat_schedule into that file and keeps the stored last_run_at of entries that still exist (beat.py). An overdue entry is sent once, then scheduled forward from now. Three things change that:
- No file, no catch-up. A new entry's
last_run_atis the current time (beat.py). A container with no volume for the file, or a change totimezoneorenable_utc, which clears it (beat.py), starts every entry fresh. beat_cron_starting_deadline(Celery 5.3 and later) limits how far back acrontabentry looks. At the defaultNone, "cronjobs that are past due will always run immediately" (configuration). Interval schedules ignore it.- Workers down while beat runs is a different outage. Beat keeps sending, the messages wait in the broker, and workers back after five hours run five copies of an hourly task. Put
expiresin the entry'soptions; a worker marks an expired taskREVOKEDinstead of running it (expiration).
APScheduler's Defaults Drop the Missed Run#
APScheduler 3.11.3's job defaults are misfire_grace_time=1 (seconds) and coalesce=True (base.py). Downtime matters only with a persistent store such as SQLAlchemyJobStore. On startup, the scheduler collects every run time that passed, keeps only the last when coalescing, and skips a run later than the grace time, logging Run time of job ... was missed by and emitting EVENT_JOB_MISSED (executors/base.py). So by default, the missed run doesn't happen. misfire_grace_time=None runs it once; adding coalesce=False runs every missed time in succession (missed job executions).
Re-adding the job at startup with replace_existing=True stores a next run time computed from now (base.py), erasing the downtime. After scheduler.start(), add it only when scheduler.get_job(job_id) returns None. Python scheduler monitoring covers listening for missed runs. The Python schedule library has no persistent store at all: a restart drops the missed run and starts every interval over, as the Python schedule library in production shows.
node-cron and In-Memory Schedulers Forget#
node-cron 4.6.0 "does not persist state to a database" (README). Its execution:missed event covers a running process that woke too late, not one that wasn't running. Any scheduler that keeps its state only in memory has nothing to catch up from after a restart. BullMQ keeps its schedules in Redis; BullMQ repeatable jobs covers what its Job Schedulers do after downtime.
Make Catch-Up Explicit With a Watermark#
Depending on the scheduler and its settings, downtime gets you no catch-up, one late run, or a replay of every missed occurrence. When every period's work must happen, let the job decide what's owed: store the last period it finished, and process every period after it. A skipped night, one catch-up run, and a 24-run Strict replay then all end with each day done once, provided a day's work is safe to repeat. Idempotent scheduled jobs shows a database watermark.
This nightly rollup processes each finished UTC day after the last one it completed. There's no CronRadar SDK for cron and no auto-discovery of crontab entries, so the script reports with its own HTTP check-ins:
#!/bin/sh
# /opt/app/daily-rollup.sh: roll up every finished UTC day not yet done, oldest first.
# Needs GNU date. rollup-day.sh must be safe to run twice for the same day.
key_file=/etc/daily-rollup/cronradar.env # sets CRONRADAR_API_KEY; readable only by this user
[ -r "$key_file" ] && . "$key_file"
url="https://cron.life/api/v1/ping/daily-rollup"
state=/var/lib/daily-rollup/last-day
run_id="daily-rollup-$(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=30 2 * * *" \
--data-urlencode "gracePeriod=1800" \
--data-urlencode "run_id=$run_id" \
"$@" "$url/$event" || true
}
report start
today=$(date -u +%F)
last=$(cat "$state" 2>/dev/null || date -u -d '2 days ago' +%F)
day=$(date -u -d "$last + 1 day" +%F)
rc=0
while [ "$(date -u -d "$day" +%s)" -lt "$(date -u -d "$today" +%s)" ]; do
/opt/app/rollup-day.sh "$day" || { rc=$?; break; }
echo "$day" > "$state.tmp" && mv "$state.tmp" "$state"
day=$(date -u -d "$day + 1 day" +%F)
done
if [ "$rc" -eq 0 ]; then
report complete
else
report fail --data-urlencode "message=rollup-day.sh exited with code $rc for $day"
fi
exit "$rc"30 2 * * * flock -n /var/lib/daily-rollup/lock /opt/app/daily-rollup.sh
@reboot sleep 300 && flock -n /var/lib/daily-rollup/lock /opt/app/daily-rollup.shThe watermark advances only after a day succeeds, so a failure stops the loop, reports fail with the day in the message, and the next run starts from that day. Cronie and Debian's cron run @reboot lines once per boot, not on a daemon restart (cronie cron.c, Debian cron.c), so catch-up starts five minutes after the host returns instead of the next night. @reboot in cron covers why such lines can fail at boot. flock -n stops the two lines from overlapping. CronRadar reads schedule in UTC, so this assumes a host on UTC.
What a Heartbeat Monitor Shows Through the Outage#
The monitor watches for check-ins from outside, so it reports the missed run whether or not catch-up happens later. For the rollup above on a host down from 01:00 to 06:00 UTC:
| Time (UTC) | What happens | Monitor |
|---|---|---|
| 02:30 | No check-in arrives | warning |
| 03:00 | Past the 30-minute grace period and at least two minutes late | critical; an incident opens |
| 06:05 | The @reboot run reports start, then complete |
healthy; the recovery is recorded |
The overdue check runs once a minute, so critical comes within about a minute of the grace period ending, and the incident alerts by email, Slack, webhook, Discord, or PagerDuty. Every check-in moves the expected time to the job's next scheduled occurrence, so the 06:05 run sets tomorrow's 02:30 as the next deadline, and its complete closes the incident with a recovery notification. With plain cron and no @reboot line, the monitor stays critical until the next night's run. A catch-up inside the grace period, such as a Persistent=true timer after a ten-minute reboot, shows warning and returns to healthy with no incident.
Extra runs aren't one of CronRadar's failure modes, so a 24-run replay records 24 successes. Telemetry is limited to 60 requests a minute per API key, and the script ignores a rejected check-in rather than failing the job. schedule and gracePeriod apply only when a check-in creates the monitor; change them later in the monitor's settings.
Frequently Asked Questions#
Does cron run missed jobs after a reboot?#
No. Cronie, Debian's cron, and BusyBox crond start from the current minute and keep no record of past runs, so a job due while the machine was down waits for its next scheduled time. For catch-up after a reboot, use anacron, a systemd timer with Persistent=true, or an @reboot line that starts a job which works out what it missed.
What is the difference between cron and anacron?#
Cron starts a command at each matching minute, and only if the daemon is running then. Anacron handles jobs whose period is a number of days: it records the date each job last ran and, when cron or a systemd timer next starts it, runs any job whose period has passed, once.
Do systemd timers run missed jobs?#
Only OnCalendar= timers with Persistent=true, and only once: when the timer starts again, usually at boot, systemd runs the service if any calendar event passed while it was stopped. A newly enabled timer waits for its next event.
What happens to a Kubernetes CronJob when the cluster was down?#
The returning controller creates one Job for the most recent missed time and skips the earlier ones. With startingDeadlineSeconds, it looks back only that far, so after a longer outage nothing runs until the next scheduled time. More than 100 missed times adds a TooManyMissedTimes warning in Kubernetes 1.37, but the Job is still created.
How do I make a scheduled job catch up after downtime?#
Store the last period the job completed, process every period after it on each run, and advance that watermark after each period succeeds. Make each period's work idempotent so a scheduler's own catch-up can't do it twice. Run the job on its schedule and at boot, with a check-in monitor that reports a missed run even if nothing catches it up.
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.