# Missed Cron Job After Reboot: What Each Scheduler Does

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/missed-runs-after-downtime

> Whether cron, anacron, systemd, Kubernetes, cloud schedulers, Quartz.NET, Hangfire, Celery, and APScheduler rerun a job missed during downtime, and how often.

**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                             |

_Figure: Hollow circles are scheduled times that fell inside the outage. After it, cron runs none of them; anacron, a Persistent=true timer, a Kubernetes CronJob, and Hangfire's default each run once; and Quartz.NET with IgnoreMisfires and a persistent store fires every missed time._

Symfony Scheduler skips runs due while no worker was running unless the schedule is `stateful()`; [Symfony Scheduler](https://cronradar.com/blog/symfony-scheduler) covers what that changes. The [CronRadar Scheduled Job Methodology](https://cronradar.com/methodology/evidence) 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](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L344-L346)), and Debian's cron does the same ([cron.c](https://sources.debian.org/src/cron/3.0pl1-197/cron.c/#L148-L158)). 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](https://cronradar.com/blog/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](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L398-L479)):

- **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](https://cronradar.com/blog/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](https://github.com/vda-linux/busybox_mirror/blob/1_38_0/miscutils/crond.c#L1080-L1137), 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)](https://man7.org/linux/man-pages/man8/anacron.8.html)). 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](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/anacron/runjob.c#L384-L392)). 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_RANGE` and `RANDOM_DELAY`.** If the range is missed, jobs "are not executed that day" ([anacrontab(5)](https://man7.org/linux/man-pages/man5/anacrontab.5.html)). Fedora ships `3-22` and `45`, and `/etc/cron.hourly/0anacron` starts anacron hourly unless the host is on battery ([anacrontab](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/contrib/anacrontab), [0anacron](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/contrib/0anacron)), so a host that stays up runs `cron.daily` between 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](https://sources.debian.org/src/anacron/2.3-43/debian/anacron.timer/), [anacron.service](https://sources.debian.org/src/anacron/2.3-43/debian/anacron.service/)), and Debian's `/etc/crontab` skips its own 06:25 `cron.daily` run when anacron is installed ([crontab](https://sources.debian.org/src/cron/3.0pl1-197/debian/crontab.main/)).

## 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)](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html), 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](https://github.com/systemd/systemd/blob/v262.1/src/core/timer.c#L704-L723):

- **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 with `OnBootSec=`.

[systemd timers](https://cronradar.com/blog/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](https://github.com/kubernetes/kubernetes/blob/v1.37.1/pkg/controller/cronjob/utils.go#L100-L182), [cronjob_controllerv2.go](https://github.com/kubernetes/kubernetes/blob/v1.37.1/pkg/controller/cronjob/cronjob_controllerv2.go#L550-L691)). 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](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/#cron-job-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](https://kubernetes.io/blog/2021/04/09/kubernetes-release-1.21-cronjob-ga/)). In 1.37.1, more than 100 missed times adds a `TooManyMissedTimes` warning event, and the Job is still created ([utils.go](https://github.com/kubernetes/kubernetes/blob/v1.37.1/pkg/controller/cronjob/utils.go#L210-L222)).

## 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](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#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](https://docs.github.com/en/actions/reference/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](https://docs.aws.amazon.com/scheduler/latest/UserGuide/what-is-scheduler.html)). When the target returns an error, it retries with exponential backoff until `MaximumRetryAttempts` (0 to 185) or `MaximumEventAgeInSeconds` (60 seconds to 24 hours) runs out ([RetryPolicy](https://docs.aws.amazon.com/scheduler/latest/APIReference/API_RetryPolicy.html)), then sends the event to an SQS [dead-letter queue](https://docs.aws.amazon.com/scheduler/latest/UserGuide/configuring-schedule-dlq.html) 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](https://docs.aws.amazon.com/lambda/latest/dg/invocation-async-error-handling.html)).
- **Google Cloud Scheduler** defaults `retryCount` to 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](https://docs.cloud.google.com/scheduler/docs/configuring/retry-jobs), checked October 24, 2026). The `X-CloudScheduler-ScheduleTime` header keeps the original scheduled time across retries, a usable deduplication key ([overview](https://docs.cloud.google.com/scheduler/docs/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](https://www.quartz-scheduler.net/documentation/quartz-3.x/configuration/reference.html#quartz-jobstore-misfirethreshold)). Downtime produces misfires only with a persistent store such as `AdoJobStore`; with `RAMJobStore`, "all scheduling information is lost" when the process ends ([job stores](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/job-stores.html)). With a persistent store, the scheduler finds misfired triggers when it starts and applies each one's instruction ([misfire instructions](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/more-about-triggers.html#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](https://github.com/quartznet/quartznet/blob/v4.6.0/src/Quartz/Impl/Triggers/CronTriggerImpl.cs#L525-L532)). A simple trigger fires now, unless it repeats forever, in which case it skips to the next scheduled time ([SimpleTriggerImpl.cs](https://github.com/quartznet/quartznet/blob/v4.6.0/src/Quartz/Impl/Triggers/SimpleTriggerImpl.cs#L265-L286)). `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](https://github.com/quartznet/quartznet/blob/v4.6.0/src/Quartz/MisfireInstruction.cs#L48-L62)). 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](https://github.com/quartznet/quartznet/blob/v4.6.0/src/Quartz/Configuration/SchedulingOptions.cs#L96-L105)). [Quartz.NET monitoring](https://cronradar.com/blog/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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/MisfireHandlingMode.cs)). [Hangfire recurring job not running](https://cronradar.com/blog/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](https://docs.celeryq.dev/en/v5.6.3/userguide/periodic-tasks.html#starting-the-scheduler)). At startup it merges `beat_schedule` into that file and keeps the stored `last_run_at` of entries that still exist ([beat.py](https://github.com/celery/celery/blob/v5.6.3/celery/beat.py#L458-L470)). 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_at` is the current time ([beat.py](https://github.com/celery/celery/blob/v5.6.3/celery/beat.py#L117-L127)). A container with no volume for the file, or a change to `timezone` or `enable_utc`, which clears it ([beat.py](https://github.com/celery/celery/blob/v5.6.3/celery/beat.py#L545-L556)), starts every entry fresh.
- **`beat_cron_starting_deadline`** (Celery 5.3 and later) limits how far back a `crontab` entry looks. At the default `None`, "cronjobs that are past due will always run immediately" ([configuration](https://docs.celeryq.dev/en/v5.6.3/userguide/configuration.html#beat-cron-starting-deadline)). 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 `expires` in the entry's `options`; a worker marks an expired task `REVOKED` instead of running it ([expiration](https://docs.celeryq.dev/en/v5.6.3/userguide/calling.html#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](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/base.py#L908-L913)). 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](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/executors/base.py#L106-L127)). 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](https://apscheduler.readthedocs.io/en/3.x/userguide.html#missed-job-executions-and-coalescing)).

Re-adding the job at startup with `replace_existing=True` stores a next run time computed from now ([base.py](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/base.py#L1067-L1083)), erasing the downtime. After `scheduler.start()`, add it only when `scheduler.get_job(job_id)` returns `None`. [Python scheduler monitoring](https://cronradar.com/blog/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](https://cronradar.com/blog/python-schedule-library) shows.

## node-cron and In-Memory Schedulers Forget

node-cron 4.6.0 "does not persist state to a database" ([README](https://github.com/node-cron/node-cron#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](https://cronradar.com/blog/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](https://cronradar.com/blog/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:

```bash
#!/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"
```

```text
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.sh
```

The 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](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L503-L529), [Debian cron.c](https://sources.debian.org/src/cron/3.0pl1-197/cron.c/#L282-L308)), so catch-up starts five minutes after the host returns instead of the next night. [@reboot in cron](https://cronradar.com/blog/cron-reboot) 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.
