April 5, 2026 · 18 min read
Everything you need to know about iOS push notifications — from how APNs works under the hood, to setting up remote and local notifications, custom actions, and the questions that come up in every iOS interview.
Push notifications are one of those iOS features that look straightforward from the outside — a banner appears, the user taps it, something happens. But once you start building a production system around them, you realise there are a surprising number of moving parts: APNs environments, device token lifecycle, background execution budgets, notification categories, the delegate method that handles three different app states. Get any one of these wrong and notifications silently stop working with no error thrown anywhere.
This article walks through how the entire system fits together — from the moment your server sends a payload to the moment the user taps an action button — with the kind of context that only comes from having debugged this stuff at 2 AM.
Before writing a single line of Swift, it’s worth understanding what Apple Push Notification service (APNs) actually is and why it exists.
Your server cannot reach a user’s iPhone directly. iPhones don’t have public IP addresses, they roam between networks, and they sleep constantly to preserve battery. APNs solves this by maintaining a persistent, encrypted connection between itself and every iOS device. Your server talks to APNs; APNs talks to the device. You never communicate with the device directly.
The end-to-end flow looks like this:
| |
And in more detail:
registerForRemoteNotifications(). iOS contacts APNs and returns a device token — a 32-byte identifier that uniquely addresses your app on that specific device.The device token is the critical piece of state here. It changes when the app is reinstalled, when the device is restored from backup, or when the user erases their device. This is why your backend should always store the latest token — uploading it every time didRegisterForRemoteNotificationsWithDeviceToken fires is the right pattern, not just on first install.
Start in Xcode under your target’s Signing & Capabilities tab. Add the Push Notifications capability. If you need silent/background notifications, also add Background Modes → Remote notifications.
This writes a single key into your .entitlements file:
This value automatically becomes production for App Store and TestFlight builds. It matters more than most developers realise — APNs has two completely separate environments (sandbox and production), and a token from one is completely invalid in the other. Sending a debug build token to production APNs returns a BadDeviceToken error. It’s a common source of confusion when notifications work in development but silently fail after release.
In AppDelegate, inside application(_:didFinishLaunchingWithOptions:):
| |
One detail that trips people up: deviceToken arrives as raw Data. You must hex-encode it before sending to your backend. Sending the raw bytes as a string produces garbage that APNs will reject.
Notifications arrive in three different app states, and each has its own entry point. Understanding this is what separates developers who’ve actually shipped notification-heavy features from those who’ve only read the docs.
| |
The app terminated state is the third scenario — covered in the deep linking section below.
The payload is a JSON object your backend constructs and sends to APNs. Understanding its structure is important because malformed payloads fail silently on the iOS side — all errors are returned to your server, never to the app.
| |
A few things worth highlighting here:
The aps key is reserved. All Apple-defined keys live inside it. Your custom data — order IDs, deep link paths, user IDs — belongs at the root level, outside aps. Putting custom keys inside aps produces undefined behaviour; APNs may strip them silently.
The payload has a hard 4KB size limit. This is why you send IDs, not content. Send "article_id": "ART-9821" and fetch the full article in the app — never send the article body in the payload.
APNs errors go to your server, not your app. If the payload is malformed, the device token is expired, or the priority is wrong, APNs returns an HTTP error to your backend. Your app receives nothing and has no way of knowing the push failed. This is why robust backend logging of APNs responses is non-negotiable in production.
The aps content determines what type of notification you’re sending:
Silent notifications (sometimes called background notifications) are frequently misunderstood. The terms describe the same mechanism from different angles: “silent” refers to the user experience (no UI), and “background” refers to the app behaviour (woken up to process data). They’re triggered by the same content-available: 1 key.
What developers often learn the hard way is that silent notifications are not reliable. iOS throttles them aggressively based on app usage patterns, battery state, and system load. They’re appropriate for non-critical data syncing — prefetching content, refreshing caches. Never use them for anything time-sensitive.
The apns-priority header matters here too. Silent notifications must use priority: 5. Sending priority: 10 with push-type: background causes APNs to immediately reject the request with BadPriority.
iOS 15 introduced a more granular system for controlling how aggressively a notification interrupts the user:
| Level | Bypasses Focus? | Bypasses DND? | Bypasses Ringer? |
|---|---|---|---|
passive | ❌ | ❌ | ❌ |
active (default) | ❌ | ❌ | ❌ |
time-sensitive | ✅ | ❌ | ❌ |
critical | ✅ | ✅ | ✅ |
time-sensitive is the sweet spot for most production apps — ride-share arrivals, OTPs, delivery alerts. It breaks through Focus modes without requiring the full criticalAlert entitlement that Apple manually reviews.
When calling requestAuthorization(options:), you pass a bitmask of UNAuthorizationOptions. The three you’ll use in almost every app are .alert, .sound, and .badge — but the full set is worth knowing:
| Option | What it unlocks | Requirement |
|---|---|---|
.alert | Banners, lock screen, Notification Center | None |
.sound | Plays sound on delivery | None |
.badge | Red count badge on app icon | None |
.provisional | Silent delivery without asking permission | None |
.carPlay | Notifications in CarPlay | None |
.announcement | Siri reads via AirPods | None |
.timeSensitive | Breaks through Focus modes | Entitlement |
.criticalAlert | Bypasses DND and ringer switch | Apple review |
.provisional deserves a mention because it inverts the usual permission model. The system dialog is never shown — the app receives limited authorization immediately, and notifications are delivered silently to Notification Center only. The user can then choose to allow or turn off from there. It’s a useful pattern for onboarding flows where you don’t want to burn the permission prompt before the user understands the value of your notifications.
If a user denies permission, requestAuthorization never shows the dialog again. The only recourse is directing them to Settings → Your App → Notifications manually. This is why the timing and framing of your permission request matters enormously — ask too early, without context, and a meaningful percentage of users will deny it permanently.
Actions are what turn a notification from a read-only message into an interactive touchpoint. When the user long-presses a notification, buttons appear — Reply, Track Order, Dismiss — and the user can act without ever opening the app.
| |
The category string is the linking pin between your backend and iOS. It must be character-for-character identical — including case — to the identifier you register. A mismatch means no buttons appear, and no error is thrown anywhere. It’s one of those bugs that wastes an hour the first time you encounter it.
| |
Action option flags are worth memorising: [] (empty) runs in the background without foregrounding the app, .foreground opens the app, .destructive renders the button title in red, and .authenticationRequired gates the action behind Face ID or Touch ID.
Handling a notification tap correctly across all three app states is where a lot of implementations fall short. The state the app is in when the user taps determines which code path executes.
Foreground and background both land in didReceive response. Handle them there.
Terminated is the special case. The app cold-launches when the user taps, which means didReceive response fires after didFinishLaunchingWithOptions. If your navigation stack isn’t ready yet, the deep link call lands before there’s anything to navigate to. The solution is to store the pending destination and let the view hierarchy pick it up once it’s mounted.
| |
| |
Local notifications are scheduled on-device — no server, no APNs, no internet connection required. From the user’s perspective they’re indistinguishable from remote notifications. They go through the same UNUserNotificationCenter pipeline, respect the same permissions, and support the same action categories.
There are three trigger types:
| |
If repeats: true, the timeInterval must be at least 60 seconds — iOS enforces this and throws an error otherwise.
One subtle difference from remote notifications: for local notifications, the category is set via content.categoryIdentifier in code. For remote notifications, it comes from the "category" key in the APNs payload. Same underlying system, different entry point.
And yes — local notifications require the same user permission as remote notifications. This surprises developers more often than it should. Schedule a local notification without authorization and it’s silently dropped. No error, no feedback.
Rich notifications extend the standard text-and-sound format with images, GIFs, audio, or video — and optionally a fully custom expanded UI.
The mechanism is a Notification Service Extension: a separate process that intercepts the notification before it’s displayed, downloads the media, attaches it, and passes the enriched notification back to iOS. The payload must include "mutable-content": 1 to trigger the extension.
The extension gets approximately 30 seconds. If it times out, serviceExtensionTimeWillExpire() is called — you must call the content handler there, or the notification is dropped entirely.
A Notification Content Extension takes this further, rendering a fully custom SwiftUI or UIKit view when the user expands the notification. It’s wired up via a category identifier that matches a registered extension target in your app.
The extension runs in a separate process from your main app with a tight memory budget (~24 MB). Keep media small — thumbnail-sized images, not full resolution. And the Notification Service Extension does not run on the Simulator; always test on a physical device.
UNUserNotificationCenter — The SingletonEverything in the notifications system flows through UNUserNotificationCenter.current(). It’s a singleton — you cannot instantiate it directly, and there is no public initializer.
This makes sense given what it manages: authorization status, pending requests, delivered notifications, and the delegate reference. All of this needs to be centralized. If multiple instances existed, they’d have conflicting views of the same system state.
It’s worth knowing how this compares to other iOS singletons:
| Class | Accessor | Instantiable? |
|---|---|---|
UNUserNotificationCenter | .current() | ❌ |
URLSession | .shared | ✅ (custom sessions) |
FileManager | .default | ✅ |
NotificationCenter | .default | ✅ |
CLLocationManager | — | ✅ |
CLLocationManager is a useful contrast — you can create multiple instances, which is appropriate because different subsystems might monitor location independently for entirely different purposes.
These are the questions that come up consistently in iOS interviews when notifications are on the table.
Q: Walk me through how APNs works end-to-end.
APNs is a relay infrastructure. The app registers with iOS, which contacts APNs and returns a device token. The app sends that token to your backend. When you want to push a notification, your backend sends an HTTP/2 request to APNs with the token and payload. APNs routes it to the device over a persistent encrypted connection it maintains with every iOS device. Your server never communicates with the device directly.
Q: What is a device token and when does it change?
A 32-byte opaque identifier that uniquely addresses a specific app on a specific device on APNs. It changes on uninstall/reinstall, device restore from backup, device erase, and when the APNs environment changes (dev to prod). Your backend must always store the latest token — uploading it on every app launch from didRegisterForRemoteNotificationsWithDeviceToken is the correct pattern.
Q: What’s the difference between a silent notification and a background notification?
They describe the same mechanism from two angles. Both are triggered by content-available: 1 in the payload. “Silent” refers to the user experience — no banner, no sound. “Background” refers to the app behaviour — the system wakes the app to process data. The terms are used interchangeably, though Apple’s API naming tends to favour “background.”
Q: Why are silent/background notifications unreliable?
iOS throttles them based on app usage frequency, battery state, and system load. Low Power Mode suppresses them significantly. They can be coalesced or dropped entirely. Never use them for time-critical data — use alert notifications with content-available: 1 if you need both user visibility and a background wake, or complement with BGAppRefreshTask for periodic fetches.
Q: What happens if you don’t implement serviceExtensionTimeWillExpire()?
The notification is dropped entirely — the user sees nothing. iOS gives the extension ~30 seconds. If you run out of time and haven’t called the content handler, the system kills the extension process and discards the notification. Always store a reference to contentHandler and bestAttemptContent at the start of didReceive so you can call the handler with whatever you have in serviceExtensionTimeWillExpire().
Q: How do you handle a notification tap when the app is terminated?
When the user taps a notification from a terminated state, iOS launches the app and passes the notification payload via launchOptions[.remoteNotification] in didFinishLaunchingWithOptions. The didReceive response delegate method is not called in this case — you must check launchOptions explicitly. Because the view hierarchy isn’t mounted yet at this point, the right approach is to store the pending deep link destination and let the root SwiftUI view pick it up via onChange once it’s ready.
Q: Do local notifications require user permission?
Yes. Local notifications go through the same UNUserNotificationCenter pipeline as remote notifications and are subject to the same authorization. Scheduling a local notification without permission results in it being silently dropped — no error is thrown.
Q: What’s the difference between .foreground and empty options [] on a UNNotificationAction?
[] (empty options) means the action is handled in the background — the app is not foregrounded, and you handle the action in didReceive response while the app remains in the background. .foreground brings the app to the foreground before the handler fires. Use [] for actions that don’t require UI (mark as read, like, dismiss) and .foreground for actions that need to navigate to a screen.
Q: What is UNNotificationDefaultActionIdentifier?
A system-provided constant that represents the user tapping the notification body itself — not one of your custom action buttons. You handle it in the same didReceive response switch alongside your custom action identifiers. This is typically where you’d put your deep link logic for the primary notification tap.
Q: Why must categories be registered every launch?
iOS does not persist UNNotificationCategory registrations across app launches. setNotificationCategories must be called every time the app starts, before any notifications arrive. The right place is didFinishLaunchingWithOptions. If a notification arrives before categories are registered, no action buttons appear.
Q: What’s the development vs production APNs distinction and why does it matter?
APNs has two entirely separate environments with separate endpoints: sandbox (api.sandbox.push.apple.com) and production (api.push.apple.com). Device tokens are environment-specific — a token from a debug build is completely invalid against the production endpoint and returns BadDeviceToken. A common bug is having staging servers use production APNs credentials against tokens from debug builds, or vice versa.
Push notifications in iOS have a lot of surface area, but the mental model is consistent once it clicks: everything flows through UNUserNotificationCenter, APNs is a dumb relay that your server talks to, device tokens are ephemeral and must be refreshed, and the three app states (foreground, background, terminated) each have their own entry point that you need to handle explicitly.
The gaps between developers who’ve shipped this properly and those who haven’t usually come down to a handful of things: understanding why silent notifications aren’t reliable, knowing that APNs errors go to your server and not your app, handling the terminated app launch state correctly, and not burning the permission prompt before the user understands why they’d want notifications in the first place.
Get those right and the rest is just API surface.