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
- APNs Registration Flow
- Handling Notifications with
UNUserNotificationCenterDelegate - APNs Payload Structure
- Rich Notifications with
UNNotificationServiceExtension - Custom Notification UI with Content Extensions
- Notification Categories and Action Buttons
- Advanced Usage
- When to Use (and When Not To)
- Summary
The Problem
Most developers learn push notification basics from a tutorial written before iOS 10. They end up with code that:
- Shows notifications only when the app is in the background — notifications sent while the app is active are silently dropped.
- Deep-links to the wrong screen (or nowhere) when the user taps a notification banner.
- 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:
requestAuthorizationreturnsfalsewithout presenting a dialog if the user has already denied permission. UsegetNotificationSettings()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
didRegisterForRemoteNotificationsWithDeviceTokencallback 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 bodyUNNotificationDismissActionIdentifier— user swiped the notification away- Any custom action identifier you registered
Tip: Set the delegate in
didFinishLaunchingWithOptions, not ininit(). 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:
| Field | Purpose |
|---|---|
alert | The visible notification content (title, subtitle, body) |
badge | Number to display on the app icon badge |
sound | "default" for system sound, or a filename in your bundle |
content-available: 1 | Triggers a silent background wake for content updates |
mutable-content: 1 | Signals that a UNNotificationServiceExtension should process this notification before display |
thread-id | Groups notifications into threads in the notification center |
category | Associates 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-availableto 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:
UNNotificationAttachmentsupports 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. SeeUNNotificationAttachmentfor 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
userInfoor 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
.p8key file you download once. The token is regenerated hourly. Recommended — no annual certificate renewal, works for multiple apps. - Certificates: A
.p12file 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)
| Scenario | Recommendation |
|---|---|
| Time-sensitive user alerts (new content, messages) | Use push with .alert, .sound, .banner |
| Silent background data refresh | Use content-available: 1 with a background task handler |
| Rich media in notifications (images, video) | Add a UNNotificationServiceExtension |
| Custom notification expanded view | Add a UNNotificationContentExtension |
| Interactive actions without opening app | Use UNNotificationAction with no .foreground option |
| Quiet trial before requesting full permission | Use .provisional authorization |
| Medical/safety/emergency alerts bypassing mute | Use critical alerts (requires Apple entitlement) |
| Sending marketing/promotional messages frequently | Avoid heavy-handedness — provide notification settings within the app; respect UNAuthorizationStatus.denied |
| Requesting permission on first launch | Avoid — wait for a contextually appropriate moment; premature prompts dramatically reduce opt-in rates |
Summary
- Set
UNUserNotificationCenter.current().delegateindidFinishLaunchingWithOptions— not later — to catch notification responses during app launch. - Implement
willPresentto show notifications when the app is active. Without it, foreground notifications are silently dropped. - Implement
didReceivewith aswitchonresponse.actionIdentifierto handle taps and custom actions with deep linking. - Use
UIApplicationDelegateAdaptorto receivedidRegisterForRemoteNotificationsWithDeviceTokenin a SwiftUI app. - Add a
UNNotificationServiceExtensionto download and attach images before display — requiresmutable-content: 1in the payload. - Register
UNNotificationCategoryobjects at launch to make action buttons available as soon as the first notification arrives. - Use
threadIdentifierto group related notifications andprovisionalauthorization for a low-friction entry point. - Test with
xcrun simctl pushon 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.