skip to content

How does a launchd job scheduled with StartCalendarInterval behave differently from the same schedule in cron when the Mac is asleep at the scheduled time?

level: middleimportance: should knowfreq 46%

answer

  1. one of them was designed for laptops
  2. a missed occurrence is not always a lost one
  3. several misses become one run
  4. the job may see the wrong wall-clock hour
  5. write it to be safely repeatable

basics

~20 s

cron simply misses the run: a sleeping machine has no cron tick, and the moment passes. launchd remembers, and runs the job when the Mac wakes. If several intervals were missed, launchd coalesces them into a single run rather than firing once per missed slot.

solid answer

~50 s

cron assumes a machine that is always on; every minute it checks the current time, so anything due while the Mac slept is simply never seen and never runs. launchd was designed for laptops that spend most of their life asleep, so a `StartCalendarInterval` job whose time passes during sleep is started when the machine wakes up, and multiple missed occurrences collapse into one run instead of a burst. That is why the maintenance job you scheduled for 3 a.m. on a laptop fires shortly after you open the lid at 9 rather than never. The practical consequences: your job must be idempotent and must not assume it is running at the wall-clock time in the plist, and you should not rely on exact-second timing at all — launchd is explicit that it schedules on a best-effort basis. Use `StartInterval` when you mean "every N seconds" and `StartCalendarInterval` when you mean a wall-clock time.

go deeper

for a junior

Know that cron skips runs that fall while the machine is asleep, while launchd runs the job after the Mac wakes, and be able to name StartCalendarInterval and StartInterval.

for a middle

Explain the coalescing rule — several missed occurrences produce a single run — and which plist fields make up a calendar interval, including that omitted fields act as wildcards.

for a senior

Draw the consequences for the job itself: idempotent work, no assumption about the observed clock time, a durable last-run marker for anything that matters, and event triggers instead of polling where possible.

for a principal

Decide where scheduling authority belongs. Client-side timers on laptops are best-effort by nature, so anything with a business deadline should be driven or verified centrally, with the local job treated as an optimisation rather than the guarantee.

## Two different assumptions about the machine cron comes from the always-on Unix server: a daemon wakes each minute, compares the clock to the crontab, and runs whatever matches. If the machine is asleep or off, no comparison happens, and the job is not run — there is no catch-up concept in classic cron at all. launchd was written for hardware that sleeps constantly. Its calendar scheduling therefore has explicit wake semantics: if the machine is asleep when a `StartCalendarInterval` occurrence is due, launchd starts the job the next time the machine wakes. And if several occurrences elapsed during a long sleep, they are coalesced into one event on wake rather than fired one after another. A daily 3 a.m. job on a laptop that was shut in a bag for a week runs once when it comes back, not seven times. ## What that means for the job you write Three properties follow directly: 1. **Be idempotent.** You may run at a time you did not choose, and a coalesced run has to stand in for several missed ones. A backup or sync that computes "what is different now" is fine; one that assumes exactly one run per day and increments a counter is not. 2. **Do not read your schedule from the clock.** Code that runs at 09:07 but assumes it is 03:00 will report nonsense, roll the wrong log, or query the wrong day's data. Take the time you actually have. 3. **Do not depend on precision.** launchd schedules best-effort; it is not a real-time timer, and a wake-driven run has no guarantee about the second it starts on. ## The keys involved - `StartCalendarInterval` takes a dictionary with any of `Minute`, `Hour`, `Day`, `Weekday`, `Month`. Omitted fields are wildcards, so `{Hour: 3, Minute: 0}` means daily at 03:00. An *array* of such dictionaries gives multiple times. - `StartInterval` takes a number of seconds and means "every N seconds", counted from the job start rather than pinned to a wall-clock time. It is the right key for "poll every ten minutes". - `WatchPaths` and `QueueDirectories` are the event-driven alternatives — run when a path changes, or when a directory becomes non-empty — and they are usually a better answer than polling on a timer. ```xml <key>StartCalendarInterval</key> <dict> <key>Hour</key><integer>3</integer> <key>Minute</key><integer>0</integer> </dict> ``` ## Where the guarantee stops The documented catch-up behaviour is about *sleep*. Do not extend it in your head to a machine that was powered off, or to a Launch Agent whose user was not logged in — an agent's domain does not exist while its user is logged out, so nothing in it can be pending. If a schedule genuinely must not be missed, the honest design is a server-side scheduler that drives the machine, or a job that checks a durable "last successful run" timestamp on every start and does the work if too much time has passed. That check is worth writing anyway, because it makes the job correct regardless of what the platform decided about wakes. ## Why launchd, not cron, on a Mac cron still exists on macOS for compatibility, but Apple's own periodic maintenance moved to launchd long ago, and everything the platform does with scheduling assumes launchd's model. Using launchd also gets you the rest of the job model for free: an identity to run as, output redirection, restart policy, and a real service target you can inspect and kickstart. A crontab entry gets you a line of text and no supervision. ## The interview point This question separates "I have configured a schedule" from "I have reasoned about a laptop". The strong answer names the wake-and-coalesce behaviour, then immediately draws the consequence for the job's code: idempotence, no assumption about the wall-clock time it observes, and a durable last-run marker for anything that actually matters.

  • A job scheduled daily at 03:00 must never be skipped on a laptop. How do you make that reliable?
    Do not rely on the schedule alone. Persist a last-successful-run timestamp and, on every start, do the work if too long has elapsed. Keep the calendar schedule as the normal trigger, but let the job's own check be the correctness argument. If it truly cannot be missed, drive it from a server that can reach the machine instead.
  • When would you pick StartInterval over StartCalendarInterval?
    When the requirement is a period rather than a wall-clock time — poll every 300 seconds, refresh a token every hour. StartCalendarInterval pins you to clock fields such as Hour and Minute and gets the wake catch-up semantics; StartInterval just spaces runs out. Choosing the calendar key for "every ten minutes" needlessly ties the job to the clock.
  • Is there an alternative to polling on a timer when the job only needs to react to a file appearing?
    Yes — WatchPaths runs the job when a given path changes, and QueueDirectories runs it while a directory is non-empty. Both are event-driven, so the job is not resident and does not wake the machine to discover nothing happened. On battery-powered hardware that difference is real, not cosmetic.

saying these in an interview costs you the question

  • Says launchd fires once per missed occurrence on wake
  • Thinks cron catches up after the Mac wakes
  • Assumes the job always runs at the plist's wall-clock time
  • Treats launchd calendar timing as second-accurate
  • Relies on catch-up for a machine that was powered off

context