Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

UserNotifications on macOS: Authorization, Scheduling, and Delivery State

Schedule local macOS notifications with contextual permission, stable request IDs, calendar triggers, cancellation, foreground handling, and delivery tests.

The UserNotifications framework lets an app request that the system present an alert, play a sound, or update a badge at a time or other supported trigger. The app submits a request; the system manages its pending delivery. Scheduling is not proof that the notification will appear exactly on time or in the same visual style on every Mac. User settings, Focus, app state, notification authorization, and system policy affect presentation.

Local notifications are appropriate for reminders and events that the user expects. They are not a substitute for background execution, a durable job queue, or a server-side guarantee. If a task must run to completion, run and persist the task itself; a notification may inform the user after the system delivers it.

Request permission in context

Before scheduling local notifications, request the interactions the feature actually uses. Explain why the app needs notifications at the moment the user enables a reminder or alert feature. The first authorization request can prompt; later calls do not repeatedly show the same prompt. A person can change allowed alert, sound, and badge behaviors later in System Settings, so inspect getNotificationSettings when the app needs to reflect current state.

Do not equate authorization with guaranteed banner presentation. The user may allow notifications while choosing a different alert style, disable sounds, or use Focus. If permission is denied, preserve the underlying app feature where possible and provide a clear path to settings rather than repeatedly asking. Keep notification content useful when displayed outside the app’s immediate context.

import UserNotifications

func scheduleReminder(identifier: String, title: String, body: String) async throws {
    let center = UNUserNotificationCenter.current()
    let allowed = try await center.requestAuthorization(options: [.alert, .sound, .badge])
    guard allowed else { return }

    let content = UNMutableNotificationContent()
    content.title = title
    content.body = body

    let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 60, repeats: false)
    let request = UNNotificationRequest(
        identifier: identifier,
        content: content,
        trigger: trigger
    )
    try await center.add(request)
}

This schedules a one-shot reminder and uses a caller-provided stable identifier. The result of add confirms that the request was accepted by the notification center, not that a banner has already appeared. In a user-facing feature, ask for authorization before scheduling and handle errors without losing the reminder’s canonical state.

Stable identifiers make updates and cancellation possible

A notification request has an identifier. Reuse the same identifier when updating a pending reminder so the notification center can replace the old request instead of accumulating duplicate alerts. Use a deterministic ID tied to the app’s own reminder record; do not generate a new random ID on every save. When the reminder is completed or deleted, remove its pending request by ID.

Pending and delivered notifications are different collections. Removing a pending request prevents a future scheduled delivery; removing a delivered notification clears it from the notification center’s delivered list. Decide whether the product should remove already delivered alerts when a record is completed. Query pending requests at reconciliation points rather than assuming the local cache of scheduled notifications never diverges after an app update or restore.

Make reconciliation idempotent: derive the desired request identifier and trigger from the canonical reminder, compare them with the pending request, and add or replace only when the desired state differs. This keeps repeated saves harmless and gives migrations a clear repair path. Avoid using the notification center as the only database for reminders, because its request inventory does not encode every product field or completion decision.

For repeating calendar triggers, store the canonical calendar components and time-zone policy with the reminder. A recurring “9 AM” can mean local wall-clock time or a fixed time zone; decide which behavior the product wants when the user travels. Calendar triggers delegate timing to the system, which is preferable to running a timer that wakes the app continuously.

Delivery when the app is active

When the app is in the foreground, the notification center delivers the notification to the app’s delegate for handling. Implement UNUserNotificationCenterDelegate if the app needs to choose presentation behavior or respond to a user action. Retain the delegate and install it early enough that delivery callbacks can be handled. The delegate should route a notification to the app’s current model by a stable identifier, not by trusting arbitrary display text.

When a user selects an action or opens a notification, validate the userInfo payload, resolve the current record, and handle a missing or deleted item gracefully. A notification may remain visible after the underlying app data changes. The notification is a pointer to app state, not the state itself.

Define notification categories and actions only for meaningful flows. A destructive or high-impact action should be explicit and should revalidate the model’s current state when invoked. Keep action identifiers stable across releases, and version custom payloads so an older delivered notification cannot crash a newer app.

Triggers and scheduling limits

Use a time-interval trigger for a relative delay, a calendar trigger for a wall-clock event, and a location trigger only when location-based behavior is genuinely needed and the app’s location configuration supports it. A trigger describes a condition; it does not run arbitrary app code at that instant. Do not put a long computation in a notification callback and expect it to finish before presentation.

For reminders derived from many records, reconcile desired requests against the notification center’s pending set. Keep only the next notifications the product needs to show and reschedule as canonical data changes. Avoid scheduling an unbounded set far into the future without accounting for product edits, recurrence changes, and system limits.

Privacy and notification content

Notifications can appear on a locked screen or in a shared environment. Put only the necessary information in the title and body. If content is sensitive, offer a generic notification that directs the user to unlock and open the app rather than including private details. Do not put authentication tokens or full database records into userInfo; include a compact stable key and load the current data after the user opens the app.

For remote notifications, server delivery and APNs are a separate pipeline. Do not conflate local requests with push notifications. A local notification schedule can function without a server; a remote notification depends on provider configuration, APNs, network delivery, and platform policies. Keep those paths separately observable in diagnostics.

Failure recovery and versioning

Treat a scheduling error as a failed side effect, not as proof that the underlying reminder should be discarded. Persist the reminder first, then reconcile its notification request. On startup or after a reminder edit, compare the desired schedule with pending requests and repair differences. If the operating system has no pending request but the canonical reminder is still active, schedule it again according to policy.

Use stable identifiers and versioned payload keys. When changing a notification format, remove obsolete requests for the prior schema or keep a compatibility parser until they expire. If the app is uninstalled or user settings change, do not tell the user that notification delivery remains guaranteed.

Acceptance checks

Test first-time permission grant and denial, later settings changes, notification scheduling and cancellation, ID-based replacement, app foreground behavior, opening a delivered notification, deleted underlying records, daylight-saving changes, time-zone travel, app relaunch, and an update between schedule and delivery. Verify both pending and delivered lists when those states matter to the product.

Log the internal reminder ID, request identifier, trigger type, request result, authorization state, app lifecycle state, and action outcome. Do not log sensitive notification text or full userInfo. Measure whether a reminder is reconciled after edit or relaunch, not whether a banner appeared at an exact second; the latter is partly controlled by the system and the user’s preferences.

The reliable pattern is canonical reminder data in the app, idempotent notification requests keyed to that data, contextual permission, and explicit handling for foreground and user-action paths. The system delivers notifications; the app owns what they mean and how stale actions are resolved.

Related:

Sources:

Comments