Push Notifications in Swift: Setup, Handling, and Rich Notifications


Push notifications are simultaneously the most powerful engagement tool in iOS and one of the most complained-about features when implemented carelessly. Get them right and you delight users with timely, relevant information. Get them wrong — skipping foreground handling, mismanaging deep links, or spamming at the wrong time — and users revoke permission within days.

This post covers the complete push notification pipeline from APNs registration through rich media notifications, action buttons, and notification grouping. It assumes you’re familiar with the SwiftUI app lifecycle and basic networking concepts. APNs server-side implementation (JWT authentication, payload construction, HTTP/2 send) is referenced but not covered in depth — that’s a backend concern.

Contents

The Problem

Most developers learn push notification basics from a tutorial written before iOS 10. They end up with code that:

  1. Shows notifications only when the app is in the background — notifications sent while the app is active are silently dropped.
  2. Deep-links to the wrong screen (or nowhere) when the user taps a notification banner.
  3. Never sends the device token to the server because the delegate method is wired up incorrectly in a SwiftUI app.

Here’s the typical buggy pattern — an app that wants to show a “New Pixar Film Announced” notification:

// ❌ Common mistakes:
// 1. No UNUserNotificationCenterDelegate set — foreground notifications are dropped.
// 2. Deep link response to tap never implemented.
// 3. Token sent from wrong thread.

@main
struct PixarFilmTrackerApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onAppear {
                    // Requesting authorization with no completion handling
                    UNUserNotificationCenter.current()
                        .requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
                    UIApplication.shared.registerForRemoteNotifications()
                }
        }
    }
}

Three things are wrong here. First, .onAppear fires every time ContentView appears — not just on first launch. Second, registerForRemoteNotifications() is called on a background continuation thread rather than the main thread. Third, and most critically, no UNUserNotificationCenterDelegate is set, so notifications delivered while the app is active are silently discarded by the system.

Let’s fix all of this.

APNs Registration Flow

The registration flow has four steps: request user authorization, register with APNs, receive the device token, and send the token to your server. In a SwiftUI app, steps two through four happen in a UIApplicationDelegate.

Step 1: Request Authorization

UNUserNotificationCenter manages notification permissions. Request authorization using the modern async API introduced in iOS 15:

import UserNotifications

func registerForPushNotifications() async {
    let center = UNUserNotificationCenter.current()
    do {
        let granted = try await center.requestAuthorization(
            options: [.alert, .badge, .sound]
        )
        guard granted else {
            // User denied — respect this. Don't request again immediately.
            // Consider showing in-app guidance instead.
            return
        }
        // Must happen on the main thread
        await MainActor.run {
            UIApplication.shared.registerForRemoteNotifications()
        }
    } catch {
        // This only throws if the app is missing the Push Notifications capability
        print("Authorization request failed: \(error)")
    }
}

Call this function once — on first meaningful engagement, not on launch. Triggering the permission dialog immediately on app open is a pattern Apple’s HIG explicitly discourages, and it dramatically reduces your opt-in rate.

Note: requestAuthorization returns false without presenting a dialog if the user has already denied permission. Use getNotificationSettings() to check current status before deciding whether to prompt or redirect the user to Settings.

Step 2: Receive and Forward the Device Token

Register delegate methods in a UIApplicationDelegate and connect it via UIApplicationDelegateAdaptor:

import UIKit
import SwiftUI

final class AppDelegate: NSObject, UIApplicationDelegate {

    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        // Convert token bytes to hex string — this is what your server needs
        let tokenString = deviceToken
            .map { String(format: "%02.2hhx", $0) }
            .joined()

        // Send to your server on a background task
        Task {
            await PushTokenService.shared.register(token: tokenString)
        }
    }

    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        // Common in Simulator — real devices should almost never hit this
        print("APNs registration failed: \(error.localizedDescription)")
    }
}

@main
struct PixarFilmTrackerApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Warning: Never cache device tokens yourself across app versions. APNs can rotate tokens, and the didRegisterForRemoteNotificationsWithDeviceToken callback fires on every launch after a fresh install or token rotation. Always forward the latest token to your server.

Checking Current Authorization Status

Before prompting, check what the user has already granted:

func checkNotificationStatus() async -> UNAuthorizationStatus {
    let settings = await UNUserNotificationCenter.current().notificationSettings()
    return settings.authorizationStatus
    // .notDetermined, .denied, .authorized, .provisional, .ephemeral
}

Handling Notifications with UNUserNotificationCenterDelegate

UNUserNotificationCenterDelegate has two critical methods: one for foreground delivery, one for tap handling. Both are absent in the “naive” implementation shown earlier.

Set your AppDelegate as the delegate in application(_:didFinishLaunchingWithOptions:):

final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // Register as delegate BEFORE the app finishes launching
        UNUserNotificationCenter.current().delegate = self
        return true
    }

    // MARK: - UNUserNotificationCenterDelegate

    /// Called when a notification arrives while the app is in the FOREGROUND.
    /// You must call the completionHandler, or the notification won't be shown.
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        // Show banner, play sound, and update badge even while app is active
        completionHandler([.banner, .sound, .badge])
    }

    /// Called when the user taps a notification (foreground or background).
    /// This is where you implement deep linking.
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        let userInfo = response.notification.request.content.userInfo

        // Extract deep-link payload and navigate
        if let filmID = userInfo["filmID"] as? String {
            Task { @MainActor in
                NavigationModel.shared.navigateToFilm(id: filmID)
            }
        }

        completionHandler()
    }
}

willPresent fires exclusively when the app is foregrounded. If the user is on the library screen when a “New Pixar Film” banner arrives, passing .banner here ensures they see it. Passing an empty set (or never calling the completion handler) silently drops the notification — which is the bug in most naive implementations.

didReceive fires for all notification taps, including taps on custom action buttons. The response.actionIdentifier distinguishes between:

  • UNNotificationDefaultActionIdentifier — user tapped the banner body
  • UNNotificationDismissActionIdentifier — user swiped the notification away
  • Any custom action identifier you registered

Tip: Set the delegate in didFinishLaunchingWithOptions, not in init(). The system can deliver a notification tap response as the app is launching (the app was backgrounded, notification arrived, user tapped it). If the delegate isn’t set by the time the lifecycle completes, that response is lost.

APNs Payload Structure

APNs sends JSON payloads over HTTP/2 to Apple’s servers, which relay them to devices. Your server constructs the payload; understanding the structure lets you request the right features:

{
  "aps": {
    "alert": {
      "title": "New Pixar Film Announced",
      "subtitle": "Coming to Disney+ this November",
      "body": "Toy Story 5 has officially been confirmed. Tap to see the trailer."
    },
    "badge": 1,
    "sound": "default",
    "content-available": 1,
    "mutable-content": 1,
    "thread-id": "pixar-announcements",
    "category": "NEW_FILM"
  },
  "filmID": "ts5-2026",
  "trailerURL": "https://pixar.com/toy-story-5/trailer"
}

Key fields:

FieldPurpose
alertThe visible notification content (title, subtitle, body)
badgeNumber to display on the app icon badge
sound"default" for system sound, or a filename in your bundle
content-available: 1Triggers a silent background wake for content updates
mutable-content: 1Signals that a UNNotificationServiceExtension should process this notification before display
thread-idGroups notifications into threads in the notification center
categoryAssociates the notification with a registered UNNotificationCategory for action buttons

Custom key-value pairs outside aps (like filmID and trailerURL above) are passed through to your delegate’s userInfo dictionary unchanged.

Warning: The entire APNs payload must not exceed 4 KB. If you need to deliver larger data, use content-available to wake the app and fetch the full content from your server.

Rich Notifications with UNNotificationServiceExtension

Standard push notifications display only text. To attach images, audio, or video — like a poster image for the “Toy Story 5 Announced” notification — add a UNNotificationServiceExtension target to your app.

The service extension is a separate process the system launches after the notification arrives and before it’s displayed. It has approximately 30 seconds to modify the notification content.

Adding the Extension Target

In Xcode: File → New → Target → Notification Service Extension. Name it FilmNotificationService. This adds a new target with a single Swift file.

Implementing the Extension

import UserNotifications

final class NotificationService: UNNotificationServiceExtension {

    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

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

        guard
            let content = bestAttemptContent,
            let attachmentURLString = request.content.userInfo["posterImageURL"] as? String,
            let attachmentURL = URL(string: attachmentURLString)
        else {
            contentHandler(request.content)
            return
        }

        // Download the poster image for the new Pixar film announcement
        downloadAndAttach(imageURL: attachmentURL, to: content) { modifiedContent in
            contentHandler(modifiedContent)
        }
    }

    override func serviceExtensionTimeWillExpire() {
        // Time is up — deliver what we have so far rather than nothing
        if let contentHandler, let bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }

    private func downloadAndAttach(
        imageURL: URL,
        to content: UNMutableNotificationContent,
        completion: @escaping (UNMutableNotificationContent) -> Void
    ) {
        let task = URLSession.shared.downloadTask(with: imageURL) { tempURL, _, error in
            guard let tempURL, error == nil else {
                completion(content)
                return
            }

            // Move the file to a stable location — URLSession cleans up tempURL after the closure
            let destinationURL = URL(fileURLWithPath: NSTemporaryDirectory())
                .appendingPathComponent(imageURL.lastPathComponent)

            try? FileManager.default.moveItem(at: tempURL, to: destinationURL)

            if let attachment = try? UNNotificationAttachment(
                identifier: "posterImage",
                url: destinationURL,
                options: nil
            ) {
                content.attachments = [attachment]
            }

            completion(content)
        }
        task.resume()
    }
}

The extension requires mutable-content: 1 in the APNs payload. Without that flag, the system never invokes the extension.

Note: UNNotificationAttachment supports JPEG, PNG, GIF, MP3, MP4, and several other formats. Maximum attachment sizes: images up to 10 MB, audio up to 5 MB, video up to 50 MB. See UNNotificationAttachment for the full list.

Custom Notification UI with Content Extensions

For fully custom notification interfaces — a movie card with a trailer thumbnail and a star rating — add a UNNotificationContentExtension target: File → New → Target → Notification Content Extension.

The content extension provides a view controller that renders inside the notification’s expanded view (long-press or 3D Touch). Configure it in its Info.plist:

<key>NSExtension</key>
<dict>
    <key>NSExtensionAttributes</key>
    <dict>
        <key>UNNotificationExtensionCategory</key>
        <string>NEW_FILM</string>
        <key>UNNotificationExtensionInitialContentSizeRatio</key>
        <real>0.6</real>
        <key>UNNotificationExtensionDefaultContentHidden</key>
        <true/>
    </dict>
</dict>

UNNotificationExtensionCategory must match the category identifier in the APNs payload. UNNotificationExtensionDefaultContentHidden hides the system-generated title/body when set to true, giving you full control of the layout.

Implement the view controller to render your custom UI:

import UIKit
import UserNotificationsUI

final class FilmNotificationViewController: UIViewController, UNNotificationContentExtension {

    @IBOutlet private var posterImageView: UIImageView!
    @IBOutlet private var titleLabel: UILabel!
    @IBOutlet private var subtitleLabel: UILabel!

    func didReceive(_ notification: UNNotification) {
        let content = notification.request.content

        titleLabel.text = content.title
        subtitleLabel.text = content.body

        // Load the attached poster image (downloaded by the service extension)
        if let attachment = content.attachments.first,
           attachment.url.startAccessingSecurityScopedResource() {
            posterImageView.image = UIImage(contentsOfFile: attachment.url.path)
            attachment.url.stopAccessingSecurityScopedResource()
        }
    }
}

Warning: Content extension view controllers run in a sandboxed process. They cannot access your main app’s data directly. Pass data through the APNs payload’s userInfo or through shared app group containers.

Notification Categories and Action Buttons

UNNotificationCategory and UNNotificationAction let you add interactive buttons to a notification — “Watch Trailer,” “Add to Watchlist,” or “Remind Me Later” for a new film announcement.

Register categories at app launch, before any notifications can arrive:

func registerNotificationCategories() {
    let watchTrailerAction = UNNotificationAction(
        identifier: "WATCH_TRAILER",
        title: "Watch Trailer",
        options: [.foreground]  // .foreground brings app to front; omit for background
    )

    let addToWatchlistAction = UNNotificationAction(
        identifier: "ADD_TO_WATCHLIST",
        title: "Add to Watchlist",
        options: []  // Handles in background without opening app
    )

    let remindLaterAction = UNNotificationAction(
        identifier: "REMIND_LATER",
        title: "Remind Me Later",
        options: [.destructive]  // Displayed in red
    )

    let newFilmCategory = UNNotificationCategory(
        identifier: "NEW_FILM",
        actions: [watchTrailerAction, addToWatchlistAction, remindLaterAction],
        intentIdentifiers: [],
        options: [.customDismissAction]  // Receive dismiss action in delegate
    )

    UNUserNotificationCenter.current().setNotificationCategories([newFilmCategory])
}

Call registerNotificationCategories() from application(_:didFinishLaunchingWithOptions:) — categories must be registered before the user can interact with them.

Handle action responses in didReceive(_:withCompletionHandler:):

func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse,
    withCompletionHandler completionHandler: @escaping () -> Void
) {
    let userInfo = response.notification.request.content.userInfo
    let filmID = userInfo["filmID"] as? String

    switch response.actionIdentifier {
    case "WATCH_TRAILER":
        guard let filmID else { break }
        Task { @MainActor in
            NavigationModel.shared.openTrailer(forFilmID: filmID)
        }

    case "ADD_TO_WATCHLIST":
        guard let filmID else { break }
        Task {
            await WatchlistService.shared.add(filmID: filmID)
        }

    case "REMIND_LATER":
        guard let filmID else { break }
        Task {
            await ReminderService.shared.scheduleReminder(forFilmID: filmID)
        }

    case UNNotificationDefaultActionIdentifier:
        // User tapped the notification body
        guard let filmID else { break }
        Task { @MainActor in
            NavigationModel.shared.navigateToFilm(id: filmID)
        }

    default:
        break
    }

    completionHandler()
}

For text input actions — like a “Reply” button that accepts typed text — use UNTextInputNotificationAction:

let replyAction = UNTextInputNotificationAction(
    identifier: "REPLY",
    title: "Reply",
    options: [],
    textInputButtonTitle: "Send",
    textInputPlaceholder: "Write a message..."
)

Retrieve the typed text from the response cast as UNTextInputNotificationResponse:

if let textResponse = response as? UNTextInputNotificationResponse {
    let text = textResponse.userText
    // Send text to your server
}

Advanced Usage

Notification Grouping with threadIdentifier

When your app sends multiple notifications, group them using threadIdentifier to prevent notification center flooding:

// In your APNs payload:
// "thread-id": "pixar-announcements"

// Or set programmatically for local notifications:
let content = UNMutableNotificationContent()
content.threadIdentifier = "pixar-announcements"

Grouped notifications appear as a stack in the notification center with a count badge (“3 announcements”). Users can expand the group or configure per-thread notification settings. This is especially important for chat and social apps.

Provisional Authorization

Provisional authorization (iOS 12+) lets you send notifications quietly — delivered to the notification center but not shown on lock screen or as banners — without asking for permission upfront. Users can then promote or turn off notifications from the notification center itself.

let granted = try await center.requestAuthorization(
    options: [.alert, .badge, .sound, .provisional]
)
// granted is always true with .provisional — the user hasn't denied yet

Provisional is a lower-friction entry point, but notifications only appear in the notification center list (no banners, no sounds, no lock screen). It’s a good strategy for news and content apps where the value of push becomes clear over time.

Critical Alerts

Critical alerts (medical, safety, emergency) bypass the mute switch and Do Not Disturb. They require an explicit entitlement from Apple — you must apply at developer.apple.com and provide a justification. Use them only for genuine emergencies.

func requestCriticalAlertAuthorization() async throws -> Bool {
    let center = UNUserNotificationCenter.current()
    return try await center.requestAuthorization(
        options: [.alert, .badge, .sound, .criticalAlert]
    )
}

APNs Authentication: JWT vs Certificates

Your server authenticates with APNs using either:

  • JWT (Token-based): A .p8 key file you download once. The token is regenerated hourly. Recommended — no annual certificate renewal, works for multiple apps.
  • Certificates: A .p12 file you export from Keychain. Expires annually and is tied to a specific app and environment. Legacy approach.

For new projects, use JWT. The key identifier and team identifier from your Apple Developer account are all you need.

Testing with simctl

Test push notifications in the Simulator without a real device or server:

# Create a payload file
echo '{"aps":{"alert":{"title":"New Pixar Film","body":"Toy Story 5 confirmed!"},"badge":1},"filmID":"ts5-2026"}' > payload.apns

# Push to a specific bundle ID and simulator
xcrun simctl push booted com.yourcompany.PixarFilmTracker payload.apns

This fires the full notification pipeline including your UNNotificationServiceExtension and delegate methods.

When to Use (and When Not To)

ScenarioRecommendation
Time-sensitive user alerts (new content, messages)Use push with .alert, .sound, .banner
Silent background data refreshUse content-available: 1 with a background task handler
Rich media in notifications (images, video)Add a UNNotificationServiceExtension
Custom notification expanded viewAdd a UNNotificationContentExtension
Interactive actions without opening appUse UNNotificationAction with no .foreground option
Quiet trial before requesting full permissionUse .provisional authorization
Medical/safety/emergency alerts bypassing muteUse critical alerts (requires Apple entitlement)
Sending marketing/promotional messages frequentlyAvoid heavy-handedness — provide notification settings within the app; respect UNAuthorizationStatus.denied
Requesting permission on first launchAvoid — wait for a contextually appropriate moment; premature prompts dramatically reduce opt-in rates

Summary

  • Set UNUserNotificationCenter.current().delegate in didFinishLaunchingWithOptions — not later — to catch notification responses during app launch.
  • Implement willPresent to show notifications when the app is active. Without it, foreground notifications are silently dropped.
  • Implement didReceive with a switch on response.actionIdentifier to handle taps and custom actions with deep linking.
  • Use UIApplicationDelegateAdaptor to receive didRegisterForRemoteNotificationsWithDeviceToken in a SwiftUI app.
  • Add a UNNotificationServiceExtension to download and attach images before display — requires mutable-content: 1 in the payload.
  • Register UNNotificationCategory objects at launch to make action buttons available as soon as the first notification arrives.
  • Use threadIdentifier to group related notifications and provisional authorization for a low-friction entry point.
  • Test with xcrun simctl push on Simulator to exercise the full pipeline without a physical device.

Once you have push notifications working, the next logical step is extending your background capabilities — firing background app refresh, processing downloads after the app is suspended, and scheduling deferred work. Explore Background Tasks in Depth for the complete picture.