macOS Background Maintenance with NSBackgroundActivityScheduler
Schedule deferrable macOS maintenance with NSBackgroundActivityScheduler, checkpoint work, honor system deferral, and verify repeatable background execution.
Periodic maintenance is easy to schedule badly. A fixed timer can wake a Mac when its CPU is busy, it is running on battery, or the user is actively working. A background task that cannot checkpoint can waste that work when conditions change. On macOS, NSBackgroundActivityScheduler gives low-priority maintenance work a flexible execution window so the system can choose a more efficient time.
Use it for work that can be delayed, split into bounded batches, and safely resumed: refreshing a cache, pruning derived data, indexing records, backing up app-owned state, or periodically fetching non-urgent content. It is not a real-time timer. Do not use it for a deadline, a user-visible immediate action, or a service that must run independently of the lifetime and activity of an application process. For always-on process supervision and calendar-style job launching, use an appropriate service or launchd design instead.
Scheduler timing is a window, not a promise
Create the scheduler with a stable reverse-DNS identifier and retain it for as long as the schedule should remain active. Apple says the system uses this identifier to track past runs and improve its scheduling heuristics, so do not generate a new UUID on every launch. Configure the interval, tolerance, repeat behavior, and quality of service before calling schedule.
For a repeating scheduler, interval is the average time between invocations after a task finishes. For a one-shot scheduler, it is a suggested interval between scheduling and invocation. tolerance defines the window around the nominal fire time; the system may run the task within that window and becomes more aggressive as the end of the grace period approaches. Its default is half the interval. A small tolerance narrows the available scheduling window and can reduce opportunities for coalescing work with other system activity.
The scheduler defaults to background quality of service. Raising quality of service asks the system to schedule more aggressively; it does not convert a deferrable task into an exact deadline. Apple describes the API as suitable for work at intervals of ten minutes or more and for other work that can be deferred. If an operation must complete before a user action, perform it as foreground work with appropriate progress and cancellation behavior rather than hoping a background window arrives in time.
A bounded, checkpointed task
The completion handler is part of the scheduler contract. Call it when the work finishes with .finished, or when the task should stop and be rescheduled with .deferred. If the handler is never called, the activity is not rescheduled. The block runs on a serial background queue appropriate for the configured quality of service, so it should not touch AppKit UI directly or block waiting for a main-thread callback.
This example uses app-specific worker methods as placeholders. The important pattern is bounded batches, a durable checkpoint before deferral, one completion on every path, and explicit invalidation when the owner no longer wants future runs:
import Foundation
final class CacheMaintenance {
private let activity = NSBackgroundActivityScheduler(
identifier: "com.example.product.cache-maintenance"
)
init() {
activity.repeats = true
activity.interval = 6 * 60 * 60
activity.tolerance = 60 * 60
activity.qualityOfService = .background
}
func start() {
activity.schedule { [weak self] completion in
guard let self else {
completion(.finished)
return
}
do {
while true {
if self.activity.shouldDefer {
try self.persistCheckpoint()
completion(.deferred)
return
}
guard try self.processNextBoundedBatch() else {
completion(.finished)
return
}
}
} catch {
self.recordFailure(error)
completion(.deferred)
}
}
}
func stop() {
activity.invalidate()
}
private func processNextBoundedBatch() throws -> Bool {
// Process one idempotent batch and return whether more remains.
false
}
private func persistCheckpoint() throws {
// Persist a restart-safe cursor or transaction boundary.
}
private func recordFailure(_ error: Error) {
// Record a sanitized diagnostic and classify retryability.
}
}
The skeleton catches errors and defers, but production code should distinguish transient from permanent failures. A malformed local database should not be retried forever as if it were a temporary network outage. Record the failure, apply a bounded retry or backoff policy, and use .deferred only when a later attempt can plausibly succeed. Ensure the checkpoint is committed before reporting deferral; otherwise the next invocation may repeat work or skip records.
Every batch should be idempotent or have an explicit transaction boundary. Persist enough state to resume after process termination, app update, or user logout. Do not assume the scheduler will continue an interrupted closure. invalidate() prevents future scheduling, but Apple documents that a block already executing still finishes. If shutdown must stop work promptly, maintain a separate cooperative cancellation flag and check it between batches.
Honor changes while the work is running
Conditions may change after a task starts. For example, a Mac can unplug from power or become busy with foreground work. Check shouldDefer within a long-running task, close or flush resources safely, persist progress, and call the completion handler with .deferred. The system can then invoke the task again under more favorable conditions, where the worker restores its checkpoint and continues.
Do not interpret .deferred as a failed task, and do not call .finished simply because the current invocation must stop. Those values communicate different scheduling outcomes. .finished says the invocation completed its work. .deferred asks the scheduler to try the unfinished work later. For a repeating scheduler, completed work is rescheduled according to its repeat interval; keeping task state durable is still the application’s responsibility.
The scheduler itself manages the appropriate process activity while its block runs based on the configured quality of service. Avoid adding a broad power assertion around deferrable work just to force it to run. A power assertion that prevents sleep solves a different problem and can defeat the reason to use a cooperative background scheduler. If the work is not actually deferrable, choose a design that states that requirement explicitly and bound its runtime and energy impact.
Know when to choose another mechanism
Use a run-loop Timer for lightweight work tied to an active run loop and a user-facing interval, not for energy-aware system maintenance. Use DispatchSourceTimer for queue-owned timing when you need an application-controlled timer, while still handling suspension, cancellation, and timer leeway. Use NSBackgroundActivityScheduler when the work is low priority and timing can move to a more efficient window.
Use launchd when the operating system should manage a process or job independently from a particular UI component. NSBackgroundActivityScheduler schedules a block in the application; it is not a replacement for a daemon plist, a watchdog, or a durable server-side job queue. This distinction prevents a common reliability failure: assuming a scheduled callback will start an application that is not running.
Keep activity identifiers stable across launches but distinct for separate maintenance tasks. If a task’s behavior changes incompatibly, version its identifier deliberately so old scheduler history does not silently shape a new job’s scheduling. Keep the scheduled block small enough to reason about, and delegate domain work to a worker that can be unit-tested without waiting for the operating system’s energy heuristics.
Verify behavior with evidence
Tests should verify the worker independently from the scheduler. Feed it a fixed dataset, force a stop after a batch, restore from the checkpoint, and prove the final output matches a full uninterrupted run. Inject transient and permanent failures to check that each path records useful diagnostics, invokes the completion handler once, and either resumes safely or stops retrying.
On a Mac, exercise the activity with AC power and battery states, with the app active and inactive, and with competing foreground CPU load. Log the stable activity identifier, invocation start and finish times, result (finished or deferred), batch count, checkpoint version, and sanitized error category. Do not assert that a background task fires at an exact wall-clock timestamp; assert that the work remains correct whenever it runs and that deferred work is eventually recoverable.
The production acceptance criteria are: the task can be delayed without violating product behavior; each invocation completes its callback; checkpoints prevent duplicate or missing work; permanent errors do not create an unbounded retry loop; invalidation stops future scheduling; and the user interface remains responsive. If any requirement depends on an exact deadline or running while the application is not present, the scheduler is the wrong primitive.
Related:
- macOS Power Assertions: Preventing Sleep Without Hiding the Reason
- Automating macOS with launchd Agents and Daemons
Sources: