Two scheduling modes, two documented ways to not run when you expected. On macOS a job set to fire every N seconds loses the firing entirely if the machine is asleep, and a job set to fire at a wall-clock time gets every missed occurrence collapsed into a single run on wake. Neither mode reports the discrepancy. The reliable pattern is to stop asking the scheduler for a guarantee it does not offer, and move the decision into the job.
On this page
Quick answer: poll often, decide in the job
Poll often, decide in the job, and record what you decided.
- Schedule frequently rather than precisely. A five-minute interval that usually has nothing to do costs almost nothing and removes the need for the firing to be exact.
- Have the job compute whether it is inside its window, in one fixed time basis.
- Have the job check a ledger keyed by operational date before acting, and write to that ledger after.
The scheduler’s only job becomes waking the process up often enough. Everything about correctness lives in code you can test.
Steps: five changes that move the decision into the job

1. Read what your scheduler actually promises
The launchd.plist manual is explicit about both modes, and the two sentences are the whole design argument.
On interval scheduling:
"If the system is asleep during the time of the next scheduled interval firing, that interval will be missed due to shortcomings in kqueue(3). If the job is running during an interval firing, that interval firing will likewise be missed."
So a sleeping laptop drops firings, and a long-running job drops its own next firing.
On calendar scheduling:
"Unlike cron which skips job invocations when the computer is asleep, launchd will start the job the next time the computer wakes up. If multiple intervals transpire before the computer is woken, those events will be coalesced into one event upon wake from sleep."
So calendar entries catch up, but three missed days arrive as one run. A job that assumes one firing means one day’s work will process one day and silently lose two.
The manual also notes the two keys "are not aware of each other," which rules out using one as a backstop for the other.
2. Pick frequency over precision
The scheduled jobs behind this site use StartInterval of 300 seconds, five minutes, for everything that has a deadline. One retention job uses a calendar entry at 02:30 because it genuinely does not care which minute it runs.
Five minutes is not a claim that the work happens every five minutes. It is a claim that the process gets a chance to look every five minutes, so a missed firing costs at most one interval instead of a day.
3. Put the window inside the job, in one time basis
Every state file in this system records the basis explicitly:
time_basis: "UTC"
operational_date: "2026-08-26"
The job compares the current UTC time against its window and returns immediately if it is outside. Local time never enters the decision. That removes daylight-saving transitions, travel, and machine-timezone changes from the correctness argument entirely, which matters because those are exactly the conditions under which nobody is watching.
4. Make the ledger the source of truth, not the firing
The job asks a different question than "did I get woken up." It asks whether the work for this operational date has already been done:
operational_date: 2026-08-26
status: waiting
reason: global_publication_window_wait
next_available_publication_at: 2026-08-26T15:00:00.000Z
With that record, a duplicate firing is a no-op and a missed firing is recovered by the next one. The scheduler can be as unreliable as it likes.
5. Give the window a recovery tail
A window that is a single instant converts one missed firing into a missed day. The publication window here opens at 15:00 UTC and stays open until 23:59, so any of the roughly hundred firings in that range can pick up work the earlier ones missed.
The tail is what makes frequent polling worth anything. Without it you have the same single point of failure, checked more often.
Caveats: keep the early exit cheap
Frequent polling is only cheap if the early-exit is cheap. A job that authenticates, fetches state, and then discovers it is outside its window is doing real work 280 times a day for nothing. Check the clock first, and read the ledger before anything expensive.
A ledger keyed by date needs the date computed the same way everywhere. Two components that disagree about when the day starts will both believe the work is theirs, and the failure looks like a race rather than a definition problem.
Sleep is not the only reason a firing disappears. The manual’s second sentence about overlapping runs matters more than it looks: a job that occasionally takes longer than its interval will drop its own next firing, and that behavior only appears under the slow conditions where you least want it.
None of this makes the schedule exact. It makes lateness safe. If a task must happen at a precise moment, a polling design is the wrong shape and you need a system that can make that promise.
Troubleshooting: missed, empty, doubled, or off-by-one runs
The job never runs. Confirm it is loaded and enabled before debugging the logic. A disabled job and a job whose window never opens look identical from the outside, and only one of them is in the scheduler’s state.
It ran, but did nothing, repeatedly. Read the reason field rather than the exit status. A run that exits cleanly because it is outside its window is correct behavior, and it should say so in its state file rather than leaving you to infer it.
It ran twice and acted twice. The ledger check is missing or is being written after the action instead of before. Claim the operational date first, then act, then record completion.
Everything is one day off. Something is computing the date in local time. Search for the place where a date is formatted without an explicit basis; there is usually exactly one.
Limits and evidence: one manual, this site’s own jobs
The quoted behavior is from the macOS launchd.plist manual on the machine running these jobs, read on 2026-08-28. The intervals, the UTC window, and the ledger fields are from this site’s own scheduled jobs and state files.
I have not measured how often firings are actually dropped here. The design assumes it happens rather than proving it, which is the right posture for a documented behavior but does mean I cannot tell you the rate. A count would need instrumentation comparing expected firings against recorded runs, and I have not built it.
The pattern is not specific to launchd. Any scheduler that can drop or coalesce firings has the same gap, and most of them can. What is specific here is the wording of the two guarantees, which is unusually clear and worth reading before designing around either one.
Sources
- launchd.plist(5) — the
StartIntervalandStartCalendarIntervaldescriptions quoted above
Related articles
Reading path, part 1 of 3: Run an agent task and check the result. Next: What a completion receipt needs before it can catch a false claim.
Stay in the loop
Get new agent guides with their test conditions and limitations. Unsubscribe anytime.
Comments
Questions, corrections, and useful counterpoints are welcome. Keep comments specific and on topic.

Leave a Reply