Skip to content
Commitwise
Menu
iOS Development

Push notifications

Ask for notification permission at a moment users say yes, register with APNs and keep the device token current, show notifications in the foreground, open the right screen when one is tapped (even on a cold start), add actions, and enrich or decrypt content with a Notification Service Extension.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The app asks for notification permission on its very first screen, before the user knows what the app does. Most people tap “Don’t Allow”, and iOS never shows that dialog again. Those who allowed it get a notification about their order, tap it, and land on the home screen instead of the order. When the app is open, notifications don’t show up at all.

Push notifications on iOS have a few rules that aren’t obvious until something goes wrong. This tutorial walks through them in the order users experience them.

New to some of these? Open what you need, or skip ahead.

Basics:How do push notifications reach a phone?

Your server can’t contact an iPhone directly. Apple runs the Apple Push Notification service (APNs), which keeps a connection to every device. When your app registers, iOS gives it a device token, a string that identifies your app on that device. The app sends the token to your server; to notify the user, the server sends a message with that token to APNs, which delivers it.

Basics:What is the app delegate?

SwiftUI apps can still have an app delegate, a class that receives app-level events from iOS (finished launching, received a device token), added with @UIApplicationDelegateAdaptor. Several notification APIs still deliver their results there.

Part Not started

Permission and registration

iOS shows the “Allow notifications?” dialog once. If the user says no, the app can’t ask again; the only way back is the Settings app. So the moment you ask matters more than anything else:

  • Bad: on the first screen, before the user knows why.
  • Good: when the user does something that needs it, like tapping “Notify me when my order ships” or turning on reminders. Show a short explanation in your own UI first, then the system dialog.

Separately from permission, the app registers with APNs to get its device token. That should happen on every launch, because the token can change (after restoring from a backup, reinstalling, or for other reasons iOS decides).

UNUserNotificationCenter.requestAuthorization(options:) presents the prompt once per install; later calls return the stored decision. Check notificationSettings() first (.notDetermined, .denied, .authorized, .provisional, .ephemeral) to choose between a pre-prompt, the system prompt, or a Settings deep link (UIApplication.openNotificationSettingsURLString). Provisional authorization (.provisional) skips the prompt and delivers quietly to Notification Center with Keep/Turn Off buttons, a good default for content users haven’t opted into yet. Registration (registerForRemoteNotifications()) is independent of authorization (silent pushes work without it) and should run every launch; tokens are per app per device per environment (sandbox vs production) and must be uploaded as hex.

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        UNUserNotificationCenter.current().delegate = NotificationRouter.shared   // before launch finishes
        application.registerForRemoteNotifications()                              // every launch
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let token = deviceToken.map { String(format: "%02x", $0) }.joined()
        Task { try? await DevicesAPI.shared.register(token: token) }               // upsert on the server
    }

    func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
        Logger.push.error("APNs registration failed: \(error)")                   // e.g. on a simulator without push
    }
}

// Called when the user taps "Notify me when it ships"
@MainActor
func enableShippingUpdates() async -> Bool {
    let center = UNUserNotificationCenter.current()
    switch await center.notificationSettings().authorizationStatus {
    case .authorized, .provisional, .ephemeral:
        return true
    case .notDetermined:
        return (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
    case .denied:
        showSettingsPrompt()            // "Notifications are off. Open Settings?"
        return false
    @unknown default:
        return false
    }
}
  • The delegate is set in didFinishLaunching, before anything else, so taps that launched the app are handled (more on that below).
  • registerForRemoteNotifications() on every launch; iOS calls back with the token, and the app sends it to the server, which updates or inserts it.
  • enableShippingUpdates checks the current status first: already allowed (done), never asked (show the system dialog now, in context), or denied (offer Settings instead of a dialog that won’t appear).

Predict the output

The user tapped 'Don't Allow' on the first launch. A week later, they turn on 'Order updates' in the app, and the app calls requestAuthorization again. What does the user see?

  • One prompt per install: check notificationSettings() → pre-prompt + request (.notDetermined), Settings deep link (.denied). Ask in context.
  • Provisional authorization for quiet delivery without a prompt.
  • registerForRemoteNotifications() every launch, independent of authorization; upload hex tokens (environment-specific) as an upsert.
  • Set the UNUserNotificationCenter delegate before didFinishLaunching returns.

Check this part

  1. How many times can an app show the system notification permission dialog?

  2. Why register for remote notifications on every launch?

  3. What does provisional authorization do?

Ask for notification permission when the user turns on something that needs it, since iOS shows the system prompt only once. Provisional authorization delivers quietly without asking. Register for remote notifications on every launch and send the device token to your server; it can change.

Part Not started

Showing and handling notifications

Two moments need code in the app:

  • A notification arrives while the app is open. By default, iOS doesn’t show it, assuming the app will show the information itself. If you want the banner anyway, you say so.
  • The user taps a notification. The app should open exactly what it’s about: the order, the chat. This must also work when the tap launches the app from scratch.

UNUserNotificationCenterDelegate.willPresent returns presentation options for foreground arrivals (.banner, .list, .sound, .badge, or none, e.g. when the user is already in that chat). didReceive handles default taps, custom actions and dismissals (with .customDismissAction), and is called for cold launches as long as the delegate is set before didFinishLaunching returns. Route through app state (a navigation path model or a deep-link URL), not by pushing view controllers from the delegate. Categories (UNNotificationCategory) with UNNotificationActions and UNTextInputNotificationAction enable quick actions; register them at launch.

@MainActor
final class NotificationRouter: NSObject, UNUserNotificationCenterDelegate {
    static let shared = NotificationRouter()
    var navigation: AppNavigation?                       // set by the root view; pending links wait
    private var pendingLink: URL?

    // A notification arrives while the app is in the foreground
    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                            willPresent notification: UNNotification) async
        -> UNNotificationPresentationOptions {
        let chatId = notification.request.content.userInfo["chatId"] as? String
        let isOpen = await MainActor.run { NotificationRouter.shared.navigation?.openChatId == chatId }
        return isOpen && chatId != nil ? [] : [.banner, .sound, .list]   // don't banner the chat you're reading
    }

    // The user tapped the notification or one of its actions (also on cold launch)
    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                            didReceive response: UNNotificationResponse) async {
        let info = response.notification.request.content.userInfo
        guard let link = (info["link"] as? String).flatMap(URL.init(string:)) else { return }
        await MainActor.run { NotificationRouter.shared.open(link) }
    }

    func open(_ link: URL) {
        guard let navigation else { pendingLink = link; return }   // UI not ready yet: keep it
        navigation.handle(deepLink: link)                           // e.g. shop://orders/1042
    }

    func attach(_ navigation: AppNavigation) {
        self.navigation = navigation
        if let link = pendingLink { pendingLink = nil; navigation.handle(deepLink: link) }
    }
}
  • willPresent decides what happens in the foreground: show a banner, except for a chat the user is already looking at.
  • didReceive gets the tap. The payload carries a link like shop://orders/1042, and the app’s navigation turns it into the order screen, the same way it handles links from emails.
  • Cold start: when a tap launches the app, didReceive can arrive before the UI exists. The router keeps the link and opens it as soon as the navigation is attached.
Spot the bugTaps that open the home screen

Tap the line with the bug.

struct ContentView: View {
    @State private var navigation = AppNavigation()

    var body: some View {
        RootView(navigation: navigation)
            .onAppear {
                UNUserNotificationCenter.current().delegate = NotificationRouter.shared
                NotificationRouter.shared.attach(navigation)
            }
    }
}

Exercisemedium

Quick reply from a notification

Chat notifications should offer “Reply” (with a text field) and “Mark as read” right on the notification, without opening the app. Register the category and handle both actions.

›

Create a UNNotificationCategory with a UNTextInputNotificationAction and a UNNotificationAction, register it at launch, and have the server send "category": "CHAT_MESSAGE" in the payload’s aps. In didReceive, response.actionIdentifier says which action was chosen.

SolutionTry it yourself first
// At launch
let reply = UNTextInputNotificationAction(identifier: "REPLY", title: "Reply",
                                          textInputButtonTitle: "Send", textInputPlaceholder: "Message")
let markRead = UNNotificationAction(identifier: "MARK_READ", title: "Mark as read")
let chat = UNNotificationCategory(identifier: "CHAT_MESSAGE", actions: [reply, markRead],
                                  intentIdentifiers: [])
UNUserNotificationCenter.current().setNotificationCategories([chat])

// In the delegate
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                        didReceive response: UNNotificationResponse) async {
    let info = response.notification.request.content.userInfo
    guard let chatId = info["chatId"] as? String else { return }

    switch response.actionIdentifier {
    case "REPLY":
        guard let text = (response as? UNTextInputNotificationResponse)?.userText, !text.isEmpty else { return }
        try? await ChatAPI.shared.send(text, to: chatId, clientId: UUID())   // the app may be in the background
    case "MARK_READ":
        try? await ChatAPI.shared.markRead(chatId: chatId)
    case UNNotificationDefaultActionIdentifier:
        await MainActor.run { NotificationRouter.shared.open(URL(string: "shop://chats/\(chatId)")!) }
    default:
        break
    }
}

The reply runs in the background with limited time, so it should be a single quick request (with a client id, so a retry doesn’t duplicate it); if it fails, save it to the chat’s outbox to send on next launch.

Interruption levels and summaries

interruption-level in the payload (passive, active, time-sensitive, critical) decides whether a notification breaks through Focus modes and the scheduled summary: time-sensitive needs the Time Sensitive Notifications capability and should be reserved for things like a courier arriving; critical needs an Apple entitlement. relevance-score orders notifications in summaries, and thread-id groups a conversation. Users can mute apps per Focus; designing low-value notifications as passive keeps the important ones trusted.

  • willPresent: foreground presentation options (suppress for the visible chat); didReceive: taps, actions, dismissals (.customDismissAction), including cold launches.
  • Route via deep links into app navigation state; buffer until the UI attaches.
  • Categories with actions and text input registered at launch; background actions get limited time.
  • interruption-level (time-sensitive needs a capability), relevance-score, thread-id.

Check this part

  1. A notification arrives while the app is open and nothing is shown. Why?

  2. Where must the notification center delegate be set to handle taps that launch the app?

  3. Which payload field lets a notification break through Focus without an Apple entitlement?

Implement UNUserNotificationCenterDelegate: willPresent decides whether a notification shows while the app is open, didReceive handles taps and actions, including taps that launch the app. Route taps to the right screen through your navigation state, and add categories with actions for quick replies.

Part Not started

Rich and encrypted notifications

The text in a push payload passes through Apple’s servers and shows on the lock screen, and the payload can’t carry an image. A Notification Service Extension is a small separate program in your app bundle that iOS runs just before showing the notification. It can download a product photo and attach it, replace placeholder text with the real (decrypted) message, or translate it. It gets about 30 seconds, and if it doesn’t finish, iOS shows the original.

The service extension (UNNotificationServiceExtension) runs in its own process, only for alert pushes with "mutable-content": 1, with a ~30 s budget and a tight memory limit (~24 MB). It can modify UNMutableNotificationContent (title, body, attachments, thread, interruption level) and must call contentHandler exactly once, including from serviceExtensionTimeWillExpire. Share code and data with the app through an App Group and a shared framework (keychain access group for decryption keys). It’s the standard way to implement end-to-end-encrypted notifications and to add images without a Notification Content Extension.

final class NotificationService: UNNotificationServiceExtension {
    private var contentHandler: ((UNNotificationContent) -> Void)?
    private var bestAttempt: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        let content = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent()
        bestAttempt = content

        guard let urlString = request.content.userInfo["imageUrl"] as? String,
              let url = URL(string: urlString) else {
            contentHandler(content)
            return
        }

        Task {
            if let attachment = try? await Self.downloadAttachment(from: url) {
                content.attachments = [attachment]
            }
            contentHandler(content)                    // always deliver, with or without the image
            self.contentHandler = nil
        }
    }

    override func serviceExtensionTimeWillExpire() {
        if let contentHandler, let bestAttempt { contentHandler(bestAttempt) }   // out of time: show what we have
        contentHandler = nil
    }

    private static func downloadAttachment(from url: URL) async throws -> UNNotificationAttachment {
        let (file, _) = try await URLSession.shared.download(from: url)
        let target = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
        try? FileManager.default.removeItem(at: target)
        try FileManager.default.moveItem(at: file, to: target)
        return try UNNotificationAttachment(identifier: "image", url: target)
    }
}
{
  "aps": {
    "alert": { "title": "Back in stock", "body": "The desk lamp you saved is available again" },
    "mutable-content": 1,
    "thread-id": "saved-items"
  },
  "imageUrl": "https://cdn.shop.example.com/img/lamp.3f9a2c1e/w400.jpg",
  "link": "shop://products/lamp"
}
  • mutable-content: 1 in the payload tells iOS to run the extension; without it, the extension never runs.
  • Always call contentHandler, with or without the image. If the download is slow, serviceExtensionTimeWillExpire delivers what’s ready.
  • Use a small image (the CDN’s 400-pixel version), since the extension has little time and memory.

Production puzzles

Spot the bugEncrypted messages shown as gibberish

Tap the line with the bug.

override func didReceive(_ request: UNNotificationRequest,
                         withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
    let content = request.content.mutableCopy() as! UNMutableNotificationContent
    Task {
        let key = try await KeyStore.shared.conversationKey(for: content.threadIdentifier)
        content.body = try Crypto.decrypt(content.body, key: key)
        contentHandler(content)
    }
}

Testing pushes

  • Simulator: drag an .apns file onto it, or xcrun simctl push booted com.example.shop payload.apns, to test handling and extensions without a server.
  • Devices: send with the APNs HTTP/2 API using the sandbox environment for development builds; tokens differ between sandbox and production, which is the usual cause of “works in TestFlight, not in Xcode” confusion.
  • Console: Apple’s Push Notifications Console sends test pushes and shows delivery logs per token.
  • Service extension: mutable-content: 1, ~30 s, ~24 MB; modify content, call contentHandler exactly once on every path, including serviceExtensionTimeWillExpire.
  • E2EE notifications: placeholder body from the server, decrypt in the extension with keys in a shared keychain group (afterFirstUnlock), fallback text on failure.
  • Test with simctl push / .apns files, sandbox vs production tokens, the Push Notifications Console.

Check this part

  1. What makes iOS run a Notification Service Extension?

  2. What does iOS show if the extension never calls contentHandler?

A Notification Service Extension can change a notification before it's shown: download an image, decrypt the text, localize it. It runs only when the payload has mutable-content: 1, has about 30 seconds, and must always call the content handler, falling back to the original content when time runs out.

Key takeaways

  • iOS shows the permission dialog once: ask when the user turns on something that needs notifications.
  • Register for remote notifications on every launch and send the token to the server.
  • Set the notification delegate at launch, show foreground notifications on purpose, and open the right screen on tap, even on a cold start.
  • A service extension can add images or decrypt text, and must always deliver something.
  • Check settings before asking; provisional authorization; Settings deep links after denial.
  • Route taps through deep links into navigation state, buffered until the UI is ready.
  • Categories and actions for quick replies; interruption levels for Focus.
  • Extensions: every path calls the handler; E2EE with placeholder bodies and shared keychain keys.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.