Platform integrations · core

Notifications

Local and push notifications — the permission that can only be asked once, the identifier semantics that prevent spam, and the payloads that wake your app.

12 min read12 min practice0/2 exercises5 recall cards

By the end you will be able to

  • Request notification permission at the moment it will actually be granted
  • Schedule, replace and cancel local notifications with identifier semantics
  • Explain the push pipeline — APNs, device tokens, payloads — and what each part owns
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

An app fires the notification-permission dialog on first launch, before showing anything. Industry measurements put acceptance for that pattern far below apps that ask later, in context. Why does the difference matter permanently?

Permission, and asking like you mean it

let center = UNUserNotificationCenter.current()

// At the moment of demonstrated value — never at cold launch:
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])

Two refinements professionals use:

  • Provisional authorizationoptions: [.provisional] skips the dialog entirely: notifications deliver quietly to Notification Centre (no banner, no sound), and the user upgrades or blocks from the notification itself. A trial period instead of a yes/no ultimatum — the right default for content apps.
  • Check before you ask. center.notificationSettings() reports the current state (notDetermined / authorized / denied / provisional). Only notDetermined can show the dialog; denied means your in-app prompt should deep-link to Settings instead of calling an API that will silently do nothing.

The review-guidelines tie-in from the shipping lesson: never gate the app on notification permission, and never ask before the user can understand why you want it — 5.1.1 violations both.

Local notifications: content, trigger, identifier

A local notification is three parts:

let content = UNMutableNotificationContent()
content.title = "Pot roast is ready"
content.body = "6 hours are up — check the temperature."
content.sound = .default
content.interruptionLevel = .timeSensitive        // needs the entitlement; use honestly

let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 6 * 3600, repeats: false)
// or: UNCalendarNotificationTrigger(dateMatching: DateComponents(hour: 8), repeats: true)

let request = UNNotificationRequest(
    identifier: "cooking-timer",                  // ← the important part
    content: content,
    trigger: trigger
)
try await center.add(request)

The identifier carries the semantics teams miss: adding a request whose identifier matches a pending one replaces it — same upsert behaviour as SwiftData's unique key, and the difference between "reminder updated" and "user receives four stacked reminders for the same task". Cancellation goes through the same handle:

center.removePendingNotificationRequests(withIdentifiers: ["cooking-timer"])
Identifier semantics + quiet hours — the two rules that keep local notifications civil.

Two pending, not three — the medication edit replaced rather than stacked, because the identifier was stable. The quiet-hours shift is app policy, not system behaviour: the system's own scheduled-summary and Focus features may hold your notification anyway, but a considerate default is yours to implement — and users notice.

Push: the pipeline and who owns what

Remote notifications involve four parties, and debugging them is knowing which link broke:

Your server ──(token + payload)──▶ APNs ──▶ the device ──▶ your app
     ▲                                            │
     └────────────(device token)──────────────────┘
  1. The app calls UIApplication.shared.registerForRemoteNotifications().
  2. The system delivers a device token to your app delegate — an opaque credential for this app on this device. Your app ships it to your server. Tokens change (restores, reinstalls), so re-send on every launch and let the server dedupe.
  3. Your server posts to APNs — authenticated with a .p8 key — targeting that token with a payload.
  4. APNs delivers when it judges best; delivery is not guaranteed and not instant.

The payload is JSON with a reserved aps dictionary; everything else is yours:

{
  "aps": {
    "alert": { "title": "Order shipped", "body": "Arriving Thursday" },
    "badge": 1,
    "sound": "default"
  },
  "orderID": "A-1042"
}

Your custom keys (orderID) are what the tap handler reads to deep-link — the navigation-path assignment from the lists lesson, fired from outside the app. Two payload variants complete the toolkit: silent pushes ("content-available": 1, no alert) wake the app briefly for background refresh — strictly budgeted, never reliable enough to build sync on: Low Power Mode or a force-quit drops deliveries to zero, and a push-driven sync sits days stale while the outbox that drains on foreground stays correct regardless; and mutable pushes ("mutable-content": 1) route through a notification service extension that can decrypt or enrich content before display — how end-to-end-encrypted messengers show message text APNs never saw.

Foreground behaviour is the delegate detail everyone hits: by default a notification arriving while your app is open shows nothing. Implement userNotificationCenter(_:willPresent:) and return [.banner, .sound] — or route the event into your UI directly.

Check yourself

Support reports: "user reinstalled the app and now gets no pushes, though Settings shows notifications allowed." What is the most likely broken link?

Implement replace-by-identifier

Write the code

Build the pending-request store with UNUserNotificationCenter's exact semantics: same identifier replaces, removeAll(withPrefix:) supports cancelling a whole feature's reminders.

Solution unlocks after 3 attempts

Route the payload

Write the code

Implement the tap handler's decision: given a payload's custom keys, produce the deep-link destination — with the fallbacks a robust handler needs.

Solution unlocks after 3 attempts
Design itSaved on this device. Never graded.

Design the notification strategy for a habit-tracking app: when the permission ask happens (and what the pre-permission screen says), the identifier scheme for per-habit reminders (support: edit time, pause one habit, pause all), your quiet-hours policy, and one use each — or a reasoned "none" — for silent and mutable pushes.

Checkpoint

You can now:

  • Time the permission ask, use provisional delivery, and read authorization state
  • Schedule with stable identifiers — replace, cancel, prefix-cancel
  • Trace the push pipeline and debug the token lifecycle
  • Handle foreground arrivals and route payload taps to deep links

The platform-integrations arc is open: widgets put your data on the home screen; notifications reach out at the right moment. StoreKit and App Intents are the natural next additions.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.