Local notifications: Swift & iOS lessons from production
Building apps that matter to users means respecting their time and attention. Local notifications—delivered by the device without needing a server—are one of the most effective ways to re-engage users, remind them of tasks, or alert them to time-sensitive information. We've learned plenty about getting them right from handling thousands of notifications across production apps in e-commerce, task management, and ride-hailing platforms.
Understanding UNUserNotificationCenter
The UNUserNotificationCenter is the single point of entry for managing local notifications on iOS. It's a singleton that handles scheduling, delivering, and responding to notifications. Before you can schedule any notification, you need to request user permission—and that permission flow matters more than most engineers realize.
Start with requesting authorization:
import UserNotifications
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
if let error = error {
print("Authorization error: \(error.localizedDescription)")
}
}Note that calling registerForRemoteNotifications() is necessary only if you plan to send remote (push) notifications later. For local-only apps, it's optional. Many developers include it anyway out of habit—it won't hurt, but it's worth understanding the distinction.
The authorization options you request should match your actual needs. Requesting .criticalAlert when you don't need it will likely be rejected during app review. Requesting only .alert when you need sound will lead to silent notifications that users miss. Get this balance right upfront to avoid rework.
Once authorization is granted, you can schedule notifications. The flow is:
- Create a
UNMutableNotificationContentobject with title, body, and sound. - Create a trigger (
UNTimeIntervalNotificationTrigger,UNCalendarNotificationTrigger, orUNLocationNotificationTrigger). - Wrap both in a
UNNotificationRequest. - Call
UNUserNotificationCenter.current().add(_:withCompletionHandler:).
Time and calendar triggers
The trigger determines when the notification fires. For most production use cases, you'll choose between time intervals and calendar-based schedules.
Time interval triggers fire after a fixed number of seconds. This is ideal for immediate reminders or short-term follow-ups:
let content = UNMutableNotificationContent()
content.title = "Order Ready"
content.body = "Your order #12345 is ready for pickup."
content.sound = .default
content.badge = NSNumber(value: UIApplication.shared.applicationIconBadgeNumber + 1)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 300, repeats: false)
let request = UNNotificationRequest(identifier: "order-ready-12345", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Failed to schedule notification: \(error.localizedDescription)")
}
}The repeats: false parameter is critical. Setting it to true creates a recurring notification—useful for daily reminders but easily overused. In production, we've seen teams accidentally create hourly notifications that spam users into disabling the app entirely.
Calendar-based triggers fire at a specific date and time, making them perfect for appointment reminders or morning briefings:
let content = UNMutableNotificationContent()
content.title = "Meeting Reminder"
content.body = "Your standup starts in 15 minutes."
content.sound = .default
var dateComponents = DateComponents()
dateComponents.hour = 10
dateComponents.minute = 45
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
let request = UNNotificationRequest(identifier: "standup-10-45", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Failed to schedule notification: \(error.localizedDescription)")
}
}When using calendar triggers with repeats enabled, iOS repeats at the same hour and minute every day. If you need weekly or monthly patterns, you'll need to handle that logic yourself—iOS doesn't provide built-in recurrence rules for local notifications.
In production, we've found that a combination of approaches works best: use calendar triggers for predictable events (like daily summaries) and time interval triggers for event-driven reminders (like "your ride is arriving in 5 minutes"). This keeps the user experience smooth without requiring a backend service to manage schedules.
Actions on notifications
A notification is only as useful as what the user can do with it. Interactive notification actions let users respond without opening the app, which dramatically improves engagement in our experience.
Define actions as UNNotificationAction objects and group them into categories:
let acceptAction = UNNotificationAction(
identifier: "ACCEPT_ACTION",
title: "Accept",
options: .foreground
)
let declineAction = UNNotificationAction(
identifier: "DECLINE_ACTION",
title: "Decline",
options: .destructive
)
let category = UNNotificationCategory(
identifier: "RIDE_REQUEST_CATEGORY",
actions: [acceptAction, declineAction],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([category])Then assign the category to your notification content:
let content = UNMutableNotificationContent()
content.title = "Ride Request"
content.body = "Driver is 3 minutes away."
content.categoryIdentifier = "RIDE_REQUEST_CATEGORY"
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: "ride-12345", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)Handle the user's action in your app delegate or scene delegate using UNUserNotificationCenterDelegate:
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
switch response.actionIdentifier {
case "ACCEPT_ACTION":
print("User accepted the ride request")
// Update your app state, make API call, etc.
case "DECLINE_ACTION":
print("User declined the ride request")
default:
print("User tapped the notification itself")
}
completionHandler()
}The completionHandler call is essential—it tells iOS that you've finished processing the action. Failing to call it can leave the system in an inconsistent state.
In production, we've learned to keep action titles short (two words maximum) and limit actions to two or three options. Users won't swipe through four actions; they'll just open the app instead, defeating the purpose.
Respect user preferences
The most important lesson we've learned about notifications is that respecting user preferences is non-negotiable. Users will disable notifications—or worse, uninstall your app—if you bombard them without consent or control.
Always check authorization status before scheduling:
UNUserNotificationCenter.current().getNotificationSettings { settings in
switch settings.authorizationStatus {
case .authorized:
// Safe to schedule
scheduleNotification()
case .provisional:
// Provisional authorization; notifications appear silently in Notification Center
scheduleNotification()
case .denied:
// User has explicitly denied; don't attempt to schedule
print("Notifications are disabled")
case .notDetermined:
// User hasn't been asked yet; you could prompt them
requestAuthorization()
@unknown default:
break
}
}Give users granular control over notification frequency and type. In a task management app, let them toggle reminders for overdue tasks, due today, and due this week separately. In an e-commerce app, separate alerts for order status, promotional offers, and restock notifications.
Store these preferences in your app's container (UserDefaults, Core Data, or your sync engine) and check them before scheduling:
let defaults = UserDefaults.standard
let remindersEnabled = defaults.bool(forKey: "notificationsEnabled")
let promotionalEnabled = defaults.bool(forKey: "promotionalNotificationsEnabled")
if remindersEnabled {
// Schedule notification
}
if promotionalEnabled {
// Schedule promotional notification
}Finally, provide an easy way for users to view and manage all scheduled notifications:
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
for request in requests {
print("Pending: \(request.identifier) - \(request.content.body)")
}
}
// Cancel specific notification
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["ride-12345"])
// Cancel all notifications
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()In production, we've found that apps with transparent notification controls and occasional, well-timed notifications see significantly better retention than apps that use notifications aggressively.
Common mistakes
- Forgetting to set the delegate. If you don't set
UNUserNotificationCenter.current().delegate = self, notifications won't respond to user actions. This is often set in your app delegate'sdidFinishLaunchingWithOptions. - Scheduling without checking authorization. Scheduling notifications when authorization is denied silently fails, creating confusion during debugging. Always check
notificationSettings.authorizationStatus. - Using repeating triggers carelessly. A repeating time interval trigger fires every N seconds forever (or until the app is reinstalled). This is rarely what you want. Be explicit about when and why a notification repeats.
- Ignoring badge count. Badges accumulate; users expect them to reset when they open the app. Set
content.badge = 0in your launch handler or resetUIApplication.shared.applicationIconBadgeNumber = 0when the app becomes active. - Scheduling too many notifications at once. iOS has a limit (around 64 pending local notifications per app). If you schedule hundreds, only the first batch will be honored. Prioritize ruthlessly.
Key takeaways
- Use
UNUserNotificationCenterto request authorization, then schedule notifications with time or calendar triggers based on your use case. - Calendar triggers are best for predictable events; time interval triggers are best for event-driven alerts.
- Implement notification actions to keep users engaged without forcing them to open the app.
- Always check authorization status and user preferences before scheduling notifications.
- Keep notifications infrequent, timely, and actionable. Respect user preferences or watch them uninstall.
- Test notification delivery thoroughly—especially around app backgrounding, permission states, and timezone changes.






Comments
Loading comments…