Local notifications let an app surface time-sensitive information — reminders, countdowns, geofence alerts — without any server or internet connection. Everything runs through UNUserNotificationCenter, the single point of contact for scheduling, delivering, and responding to notifications on iOS, iPadOS, macOS, and watchOS.
This guide covers the complete lifecycle: requesting permission, composing content, picking a trigger, adding interactivity with notification categories, and wiring the delegate into a SwiftUI app that observes notification responses.
Requesting Permission
The system gates every notification behind explicit user consent. Before scheduling anything, check the current authorization status and, when it is .notDetermined, present the permission dialog.
import UserNotifications
func requestAuthorization() async throws -> Bool {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
return true
case .denied:
return false
case .notDetermined:
return try await center.requestAuthorization(
options: [.alert, .sound, .badge]
)
@unknown default:
return false
}
}
The .provisional option delivers notifications quietly — they appear in Notification Center without a banner or sound until the user explicitly keeps or turns them off. Provisional authorization is granted immediately without a dialog, making it a low-friction entry point for apps that want to earn explicit permission over time.
In SwiftUI, trigger this check from a .task modifier on the root view or from a notification-manager observable object. Never call requestAuthorization on app launch without user context; iOS may throttle repeated requests and the authorization dialog should appear at a moment that makes sense to the user.
Composing Notification Content
A notification's visible and audible properties live in UNMutableNotificationContent. The only truly required field is title; everything else is optional.
let content = UNMutableNotificationContent()
content.title = "Water break"
content.subtitle = "Hydration reminder"
content.body = "You set a reminder to drink water every 30 minutes."
content.sound = .default
content.badge = 1
Beyond the basics, a few properties deserve attention:
- interruptionLevel — values are
.passive,.active(the default),.timeSensitive, and.critical. Time-sensitive notifications can cut through Focus modes; critical alerts bypass Do Not Disturb entirely but require a special entitlement from Apple. - userInfo — a
[AnyHashable: Any]dictionary carried through to the delegate. Store the identifiers or route information your app needs to act on the response. Keep values plist-compatible: strings, numbers, booleans, arrays, and dictionaries. - categoryIdentifier — links this notification to an action category registered at launch, covered in the Categories and Actions section below.
- threadIdentifier — groups related notifications in Notification Center under a single thread, keeping your app's alerts organized.
Choosing a Trigger
Every UNNotificationRequest needs a trigger. Three concrete types cover almost every use case; passing nil as the trigger fires the notification immediately, which is useful during development.
Time Interval
UNTimeIntervalNotificationTrigger fires after a fixed number of seconds. When repeats is true, the interval must be at least 60 seconds.
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: 30 * 60, // 30 minutes
repeats: true
)
Calendar
UNCalendarNotificationTrigger fires at a specific point in the user's local calendar. You build it from DateComponents; any component you omit is treated as a wildcard. Specifying only hour and minute fires the notification every day at that time.
var components = DateComponents()
components.hour = 9
components.minute = 0
let trigger = UNCalendarNotificationTrigger(
dateMatching: components,
repeats: true
)
Location
UNLocationNotificationTrigger fires when the device crosses the boundary of a CLCircularRegion. Control whether it fires on entry, exit, or both by setting the region's notifyOnEntry and notifyOnExit properties. Location triggers require the Location capability and at minimum WhenInUse authorization; reliable background delivery needs Always authorization.
import CoreLocation
let region = CLCircularRegion(
center: CLLocationCoordinate2D(latitude: 37.3317, longitude: -122.0307),
radius: 200,
identifier: "apple-park"
)
region.notifyOnEntry = true
region.notifyOnExit = false
let trigger = UNLocationNotificationTrigger(region: region, repeats: false)
Scheduling the Request
All three trigger types slot into the same scheduling call:
let request = UNNotificationRequest(
identifier: "water-break-\(UUID().uuidString)",
content: content,
trigger: trigger
)
do {
try await UNUserNotificationCenter.current().add(request)
} catch {
// Handle scheduling errors
}
Use a stable, predictable identifier when you intend to cancel or replace a notification. Use a UUID-based identifier when each notification is independent and you manage them by fetching the pending list.
Categories and Actions
Notification categories make notifications actionable. The user sees buttons beneath the notification content — in the lock screen banner, the Notification Center entry, and on long press — and each tap delivers a UNNotificationResponse with the chosen action's identifier.
let snoozeAction = UNNotificationAction(
identifier: "SNOOZE_ACTION",
title: "Snooze 5 min",
options: []
)
let dismissAction = UNNotificationAction(
identifier: "DISMISS_ACTION",
title: "Dismiss",
options: [.destructive]
)
let category = UNNotificationCategory(
identifier: "WATER_BREAK",
actions: [snoozeAction, dismissAction],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current()
.setNotificationCategories([category])
Register all categories once at app startup, before any notification is scheduled. Link a notification to its category by setting content.categoryIdentifier = "WATER_BREAK" when composing the content.
For categories that need free-form text input — a quick reply or a note — use UNTextInputNotificationAction. The user's typed string arrives in the delegate via UNTextInputNotificationResponse.userText.
The Delegate and Response Handling
The UNUserNotificationCenterDelegate protocol has two methods. Both are optional, but in practice you will implement both.
userNotificationCenter(_:willPresent:withCompletionHandler:) is called when a notification arrives while the app is in the foreground. Without this method returning a non-empty UNNotificationPresentationOptions, foreground notifications are silently absorbed — no banner, no sound. Return [.banner, .sound, .badge] to present them the same as background arrivals.
userNotificationCenter(_:didReceive:withCompletionHandler:) is called when the user taps the notification or one of its action buttons. The UNNotificationResponse carries the action identifier and the full notification, including its content and the userInfo dictionary stored at scheduling time.
import UserNotifications
@Observable
final class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
override init() {
super.init()
UNUserNotificationCenter.current().delegate = self
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound])
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
switch response.actionIdentifier {
case "SNOOZE_ACTION":
scheduleSnooze(userInfo: userInfo)
case "DISMISS_ACTION", UNNotificationDismissActionIdentifier:
break
default:
// UNNotificationDefaultActionIdentifier — plain tap
handleTap(userInfo: userInfo)
}
completionHandler()
}
}
Assign the delegate as early as possible — ideally in the initializer, so it is in place before the app finishes launching. In a SwiftUI app, create the manager in the App struct and inject it into the environment:
@main
struct MyApp: App {
@State private var notificationManager = NotificationManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(notificationManager)
}
}
}
Any view that needs to react to notification taps — routing to a specific screen, updating state — can read the manager with @Environment(NotificationManager.self) and observe the properties published from the delegate callbacks.
Managing Pending and Delivered Notifications
Notifications live in two queues. Pending notifications are scheduled but not yet delivered; delivered notifications have been shown and remain in Notification Center until removed.
let center = UNUserNotificationCenter.current()
// Inspect pending
let pending = await center.pendingNotificationRequests()
let ids = pending.map(\.identifier)
// Cancel specific pending
center.removePendingNotificationRequests(
withIdentifiers: ["water-break-123"]
)
// Cancel all pending
center.removeAllPendingNotificationRequests()
// Inspect delivered
let delivered = await center.deliveredNotifications()
// Remove specific delivered
center.removeDeliveredNotifications(
withIdentifiers: ["water-break-123"]
)
// Remove all delivered
center.removeAllDeliveredNotifications()
There is no API to edit a pending notification in place. To update one — changing its fire time or body text — remove it by identifier, create a new UNMutableNotificationContent and trigger, and schedule a fresh request using the same identifier.
Apps are limited to 64 pending notifications at a time. The system silently discards any scheduled beyond that limit, starting with those furthest in the future. If your feature requires more than 64 slots — a habit app scheduling a notification for each day of the year — schedule the nearest 64 and reschedule whenever the app returns to the foreground.
Frequently Asked Questions
Why doesn't my local notification appear when the app is in the foreground?
Foreground delivery is suppressed unless you implement userNotificationCenter(_:willPresent:withCompletionHandler:) and pass a non-empty UNNotificationPresentationOptions value — for example [.banner, .sound] — to the completion handler. If the delegate is nil or the method is not implemented, the system absorbs the notification silently.
Can I schedule more than 64 local notifications?
No. The system enforces a hard limit of 64 pending notifications per app and silently discards the rest. The standard workaround is to schedule the 64 soonest-due notifications and reschedule when the app enters the foreground, using the scenePhase environment value or a UIApplicationDelegate lifecycle callback.
How do I navigate to a specific screen when the user taps a notification?
Store a route identifier in content.userInfo when scheduling. In the delegate's didReceive method, read that key and update a published property on your notification manager. Observe that property in your view hierarchy to trigger a navigation push or sheet presentation. Storing the pending route in the manager — rather than firing a closure or posting a notification directly — ensures the view picks it up on the next render cycle, including cold launches.
Do location-triggered notifications fire while the app is suspended?
Yes. The system continues monitoring the registered CLCircularRegion even while the app is suspended, and fires the notification when the device crosses the boundary. This requires Always authorization from Core Location and the Location background mode enabled in the target's capabilities.
What is the difference between provisional and full notification authorization?
Full authorization shows the standard permission dialog and, if granted, delivers notifications with banners, sounds, and badges. Provisional authorization is granted silently — no dialog — and delivers notifications quietly to Notification Center only. The user sees a prompt in Notification Center to either keep notifications (upgrading to full) or turn them off. Provisional is useful for demonstrating your notification's value before asking for full banner permission.




