7.1 — Push Notifications (APNs)
Opening scenario
The product team says: “We need to ping users when a friend messages them.” Sounds simple — until you realize there are three lifecycles (foreground, background, killed), four payload categories (alert, sound, badge, content-available), two delivery priorities, three extensions you might want (Service, Content, Notification), a per-app opt-in, a per-user revocation, and a server certificate or auth token that will expire on a holiday. Push is one of the densest topics in iOS — and most engineers learn it twice: once incorrectly, once correctly.
| Context | What it usually means |
|---|---|
| Reads “registers for remote notifications” | Has wired the basics |
| Reads “APNs token” | Knows there’s a device-side token |
| Reads “silent push” | Has shipped background refresh notifications |
Reads “UNNotificationServiceExtension” | Has decrypted/customized payloads |
| Reads “Time-Sensitive interruption level” | Has dealt with Focus and the modern delivery model |
Concept → Why → How → Code
Concept
Apple Push Notification service (APNs) is a TLS-based message bus between Apple’s servers and every Apple device. Your server sends a JSON payload addressed to a device token; APNs delivers it (or doesn’t — APNs is “best effort”); the OS displays/processes it according to the payload and the user’s notification settings.
Three actors:
- Provider (your server). Has an APNs auth key (
.p8) or certificate, the bundle ID, the device token, and the payload. - APNs (Apple). Routes by device token, throttles silent pushes, holds messages briefly when offline.
- Device (your app). Registers, receives the token, persists it (forward to your server), receives notifications.
Why
Without push: the only way your app processes external events is polling — which destroys battery and is laggy. With push: instant delivery, Focus-aware presentation, the ability to wake the app for ≤30s background work, the ability to update Live Activities and widgets via push.
How — registering and receiving the token
import UIKit
import UserNotifications
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ app: UIApplication,
didFinishLaunchingWithOptions opts: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().delegate = self
Task { await requestAuthorization() }
return true
}
@MainActor
func requestAuthorization() async {
let center = UNUserNotificationCenter.current()
do {
let granted = try await center.requestAuthorization(
options: [.alert, .badge, .sound, .provisional]
)
guard granted else { return }
UIApplication.shared.registerForRemoteNotifications()
} catch {
print("Authorization error: \(error)")
}
}
func application(_ app: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
Task { await TokenUploader.upload(token) }
}
func application(_ app: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("APNs registration failed: \(error)")
}
}
.provisional is the modern trick: notifications arrive quietly to the notification center without ever interrupting the user, so you can deliver value before asking for full permission. After a few notifications, the user can tap “Keep” to upgrade to interruptive.
Foreground presentation
By default iOS suppresses the banner if your app is foregrounded. Override:
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async
-> UNNotificationPresentationOptions {
return [.banner, .list, .sound, .badge]
}
Tap handling
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
let userInfo = response.notification.request.content.userInfo
if let convoID = userInfo["conversationId"] as? String {
await AppRouter.shared.openConversation(id: convoID)
}
}
Silent / background pushes
To wake your app for background work, the payload must include "content-available": 1 and the request must be sent with apns-priority: 5 (background) and apns-push-type: background:
{
"aps": {
"content-available": 1
},
"syncCursor": "abc-123"
}
In code:
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async
-> UIBackgroundFetchResult {
let cursor = userInfo["syncCursor"] as? String
await SyncEngine.shared.pull(from: cursor)
return .newData
}
APNs rate-limits silent pushes aggressively — typically 2–3 per hour per app when the device is on Low Power Mode. Don’t design a feature whose UX assumes silent push always arrives.
Rich notifications with UNNotificationServiceExtension
When you add a Notification Service Extension target, you get a 30-second window after delivery to modify the payload — decrypt end-to-end-encrypted text, download a thumbnail, fetch the message body, etc.
import UserNotifications
final class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttempt: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttempt = (request.content.mutableCopy() as? UNMutableNotificationContent)
guard let bestAttempt else { return }
if let urlString = request.content.userInfo["image-url"] as? String,
let url = URL(string: urlString) {
Task {
if let attachment = await downloadAttachment(url) {
bestAttempt.attachments = [attachment]
}
contentHandler(bestAttempt)
}
} else {
contentHandler(bestAttempt)
}
}
override func serviceExtensionTimeWillExpire() {
if let contentHandler, let bestAttempt {
contentHandler(bestAttempt)
}
}
private func downloadAttachment(_ url: URL) async -> UNNotificationAttachment? {
guard let (data, _) = try? await URLSession.shared.data(from: url) else { return nil }
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".jpg")
try? data.write(to: tmp)
return try? UNNotificationAttachment(identifier: "image", url: tmp)
}
}
For the OS to invoke your extension, the payload must include "mutable-content": 1.
Interruption levels (iOS 15+)
{
"aps": {
"alert": { "title": "Garage door left open" },
"sound": "default",
"interruption-level": "time-sensitive"
}
}
Levels:
passive— silent landing in Notification Center.active(default) — normal.time-sensitive— pierces Focus modes if the user has allowed it.critical— pierces Do Not Disturb and Silent switch; requires special entitlement approved by Apple.
Provider-side: APNs auth key (modern)
Use a .p8 auth key, not certificates. One key works across all environments and your apps in the team:
# JWT (ES256) signed with your .p8 key
curl -v --http2 \
--header "authorization: bearer $JWT" \
--header "apns-topic: com.yourname.App" \
--header "apns-push-type: alert" \
--header "apns-priority: 10" \
--data '{"aps":{"alert":"Hello"}}' \
https://api.push.apple.com/3/device/$DEVICE_TOKEN
For sandbox (debug builds): api.sandbox.push.apple.com. Production builds (App Store, TestFlight) hit api.push.apple.com.
In the wild
- WhatsApp / Signal / iMessage — payload contains only
mutable-content: 1and an encrypted blob; the Service Extension decrypts and rewrites the alert before display. The OS never sees plaintext. - Slack — uses Notification Content Extensions for inline replies and previewing attachments.
- Uber Eats — Live Activities driven by APNs push; the same token plumbing, different
apns-push-type: liveactivity. - Tesla — silent pushes wake the app to refresh vehicle state when the user opens the widget.
- Apple Wallet — Wallet passes have their own push channel (
pkpasspush) using the same APNs infrastructure.
Common misconceptions
- “The token is permanent.” It changes after reinstall, after restoring from backup to a new device, after iOS major upgrades sometimes. Re-upload on every launch (cheap if the server idempotently dedupes).
- “Silent push is guaranteed.” It is budgeted. The OS may delay or drop them; Low Power Mode disables them entirely. Design for “eventually” not “instantly.”
- “
requestAuthorizationis a one-time call.” The user can revoke at any time. CallUNUserNotificationCenter.current().getNotificationSettings()on launch and reconcile. - “Production and sandbox tokens are interchangeable.” They aren’t. A token minted with an Xcode debug build is sandbox-only; sending it to the production APNs gateway returns 400 Bad Request.
- “I’ll just put the chat message in the alert body.” Compliance and privacy reviewers will not love this. End-to-end-encrypted apps must use the Service Extension pattern.
Seasoned engineer’s take
Push is half client, half server, and the server half is where 80% of the bugs live. The thing nobody tells you: invest in observability before you ship the feature. Log every push attempt with a correlation ID, the topic, the priority, the response from APNs (200 OK, 410 Gone, 429 TooManyRequests), and store it in your existing telemetry. When a user reports “I didn’t get the notification,” you need to answer “we sent it, APNs returned 200, your device is unreachable on Wi-Fi” — not “huh, weird.”
Two more things from a decade of pushes:
- Treat 410 Gone as “remove this token, don’t ever send again.” Devices that uninstall the app keep their tokens marked until you call again — and APNs charges your reputation when you spam.
- Build a push playground screen in your debug build that lets you fire any local payload to test rich content, custom sounds, interruption levels, and Live Activities without round-tripping to your server.
TIP: Use
apns-collapse-idto dedupe a stream of pushes for the same logical event (e.g., “new email” — only show the latest count). Saves your users from notification spam during sync storms.
WARNING: Never embed user data in the alert body sent through APNs in a region that requires data residency. APNs servers may transit through US infrastructure. The Service Extension pattern (encrypted blob, decrypt on device) is the only compliant approach for GDPR-strict apps.
Interview corner
Junior: “How does an iOS app receive a push notification?”
Request user permission via
UNUserNotificationCenter. If granted, callregisterForRemoteNotifications(). iOS returns a device token indidRegisterForRemoteNotificationsWithDeviceToken. Send that token to your server. Your server makes an authenticated HTTP/2 request to APNs with the token and a JSON payload. APNs delivers it to the device, which presents the notification according to the app’s foreground/background state and user settings.
Mid: “Walk me through implementing E2E-encrypted message notifications that show the decrypted message preview.”
Server sends a payload with
mutable-content: 1, the encrypted message as a custom field, and a generic placeholder title. Add aUNNotificationServiceExtensiontarget. IndidReceive, fetch the local decryption key (from Keychain shared via App Group), decrypt the message, setbestAttempt.titleandbody, callcontentHandler. ImplementserviceExtensionTimeWillExpireto flush a fallback. The OS never sees the plaintext; the network never carries it.
Senior: “Design the push notification reliability layer for a chat app at 100M MAU scale.”
Server side: sharded queue keyed by token; per-token rate limiter; HTTP/2 multiplexed connections to APNs (one connection handles thousands of requests); separate priority lanes for high-priority alerts vs background syncs; mandatory correlation IDs in payload for end-to-end tracing. Client side: every received notification reports a delivery receipt via a lightweight HTTP ping (or the next foreground sync) so server-side analytics can compute a real delivery rate. Token lifecycle: every cold launch re-uploads token; server treats 410 as a hard delete; APNs feedback periodically reconciles. Observability: per-token, per-day delivery rate dashboard, alert on regional drops (often Apple-side issues). Fallback: critical messages also push via SMS through a separate provider if no delivery receipt within 60s.
Red flag: “We send the full message in the payload because rich notifications need it.”
Demonstrates the candidate doesn’t know about
UNNotificationServiceExtensionand doesn’t think about privacy. Bonus red flag if they say “but the channel is encrypted” — APNs is encrypted in transit, but Apple’s servers see plaintext bodies. The Service Extension exists precisely for this.
Lab preview
Lab 7.2 — Widget extension wires APNs notifications into a Live Activity. You’ll set up the auth key, the curl command, and observe the activity update from a Terminal-driven push.
Next: 7.2 — WidgetKit