Native iOS Push Notifications: Permission and Restraint
How to design native iOS push notifications people keep enabled: ask at the right moment, choose local or remote delivery, and test state on real devices.
The worst time to ask for notification permission is the first screen after install, when the user has not done anything useful or learned whether the app is worth reopening, yet they see an iOS alert asking whether your product may interrupt their day, and a no is usually the end of the conversation.
I have not shipped a case-study-backed remote push system, so I will not pretend to have APNs delivery-receipt war stories, but the adjacent native work is still relevant: local notifications, a WidgetKit extension fed through an App Group snapshot, and a safety app where stale state must never look fresh, because a notification promises something about the world at a particular time and most apps make that promise too easily.
Permission is earned after a useful moment
Ask after the app has shown the exact future benefit it wants permission to deliver, as in a child health log after a caretaker enters a medicine interval and elects a reminder, when they have said a future time matters.
Explain, “I can remind you when this medicine is due again,” then show the system prompt only if they choose to enable it, which changes the question from “Can this unknown app send alerts?” to “Do I want this reminder I just set?” so they have context and a specific payoff, and can decline without losing the product.
First-launch prompts also compete with location, photos, sign-in, and every instruction, so users are not evaluating four separate requests but deciding whether an app appears desperate for access.
Use three steps:
- Let the person use the core flow without a notification request.
- Put the reminder or alert setting in that flow, with a concrete sentence about timing and content.
- Ask the operating system only after they deliberately enable that setting.
The in-app setting is the product contract, while the system grants the channel and your setting says which events may use it, but do not build a fake pre-prompt whose only job is to push “Allow”; explain a real choice, including what works without permission: a health log can record a dose, a travel journal can save a memory, and a drone safety app can show its last cached zones.
After a refusal, show a quiet setting row with a deep link to system settings when the user wants it, but do not turn completed tasks into another permission campaign because the operating system has recorded the answer.
Most events do not deserve an interruption
If the person would not thank you for seeing it now, do not send it, because feature announcements, analytics milestones, and a two-day retention gap serve the company rather than the person, so before an event becomes a notification, ask:
Is there a time-sensitive action? A medicine interval ending has one; an invitation to return to a journal does not.
Will missing it cause a cost or remove a choice? A reminder can prevent an avoidable missed step, while a completed background sync belongs in the app, not on the Lock Screen.
Can the state change before delivery? A 9am reminder may be wrong at 1pm because the record was edited, the task completed, or reminders were disabled.
Would I still send it if it were the tenth notification this week? Design the budget at the account level, not feature by feature, because three teams can each create billing, activity, recommendation, promotion, and reminder alerts while the user sees one app lighting up their phone.
Model eligibility centrally: authorisation, enabled categories, last interruption, more-important pending alerts, and whether the event remains true; a small native app needs one explicit policy function rather than a separate service or actor, because if every feature schedules directly it will drift toward noise, while safety alerts may warrant a higher frequency but need a stated upper bound, not a loop; in SkyCheck, the airspace product I built, the client starts unavailable and, after a failed location check, keeps a previous verdict for only five minutes before returning to unavailable rather than showing an unjustified clear result, while the Worker returns UNAVAILABLE, not CLEAR, when bundle loading or point-in-polygon checking fails, and push needs the same standard: an alert claiming a restriction changed needs data fresh enough to support it, otherwise say less.
Local notifications are often the honest implementation
A local notification is scheduled on-device and does not require your server to call APNs at delivery, while a remote notification begins on your server, passes through APNs, and reaches a device when Apple and the network allow it.
Choose local delivery when the trigger and relevant state live on the device - a timer, user-entered appointment, or personal reminder - because the app can schedule it as the person acts, without a round trip or a server scheduler added because “push” sounds serious.
MyKiddos follows this pattern: a parent enters a medicine name, dose text, and optional minimum interval, which is parent-entered record data rather than medical advice, and local support can remind a caretaker about the chosen time without implying the app knows whether a dose is appropriate.
Choose remote delivery when another device creates the event, people must coordinate, or server-owned data determines it, as with a shared assignment, security event, or user message. Remote is a best-effort prompt to refresh state rather than a transaction receipt: it adds token registration and churn, APNs credentials, backend jobs, failure handling, privacy review, and delivery-state ambiguity, while a server cannot guarantee that someone saw a banner, read it, or was reachable.
A server can sync reminders while the app is active, then the app schedules local notifications from that schedule, reducing server fan-out but requiring clean replacement when another device changes a reminder, so do it only when cross-device timing is real.
Use stable identifiers for the item rather than its current scheduling attempt: on change, remove the pending request and schedule its replacement, and on completion, remove it, which avoids three alerts after someone edits a date twice, while keeping alert text sufficient to decide whether to open it and an opaque identifier in userInfo or remote payload data so a tap loads current state rather than displaying an embedded record snapshot as immutable.
Widgets and notifications need one state contract
A widget is an extension process rather than your app in a small rectangle, so in MyKiddos the WidgetKit extension does not read Core Data directly. After launch, focused-child changes, and Core Data saves, the app recomputes a small Codable WidgetSnapshot, atomically writes widget-snapshot.json into the shared App Group container, then calls WidgetCenter.shared.reloadAllTimelines(), and the widget reads only that snapshot.
It contains the selected child’s active-episode state, latest temperature, dose-gap state, and last medicine - derived values the widget renders, not the health-record database, every relationship, or a second persistence implementation.
Notifications need the same discipline: if the widget says a medicine is ready at 14:30 and a notification says 14:00, the product lies twice, so compute the due date and state once, as raw dates and identifiers rather than rendered prose; here, dose state is a date, so the widget timeline has an entry exactly when the parent-entered interval ends, with a 30-minute refresh fallback for day rollovers and other state, and the widget can flip, the app can explain, and a notification can schedule from that date.
Atomic replacement matters for health-related status because the widget must decode a complete old snapshot or a complete new one, so publish after committing the data change and replace its notification only after accepting that change.
Privacy is UI work: the widget marks health-status and value lines privacySensitive, and notification previews need an equally deliberate choice because a Lock Screen may be visible to a colleague, family member, or someone picking up the phone, so “Reminder due” may be enough while a medicine name or symptom may not.
State changes make scheduled alerts stale
A scheduled alert is a snapshot of intent, so invalidate it as the world changes.
If someone records a medicine at noon then corrects it at 12:05, the old due time may be wrong, while deleting the log entry makes its request wrong and shared-caretaker changes can make a local alert wrong through CloudKit sync, so data mutations need an explicit notification-reconciliation point.
Do not scatter add and removePendingNotificationRequests through SwiftUI views, which appear and disappear for reasons unrelated to business state. Put the decision beside the mutation layer or in a coordinator after a successful save, and make it idempotent: derive the pending identifiers that should exist from the current model, then reconcile them after a reminder change, relevant synced change, and foreground return.
Foreground reconciliation is a repair path rather than a substitute for mutation handling, because mobile processes die, background execution is constrained, and CloudKit changes arrive inconveniently, so the repair must be repeatable and must not invent an alert because it cannot tell whether one exists.
SkyCheck likewise caches successful API responses with If-None-Match when it has an ETag, which lets it render cached zones after network failure, but a prior location verdict survives only five minutes after a failed fresh check, because cached is useful while current is a different claim.
Remote alerts should include an event version or update time, then on tap load the latest record and handle one already resolved, but do not make a notification the only path to a change: permission can be denied, a category disabled, the device offline, or the banner cleared, so use an inbox, badge, changed-state indicator, or normal data refresh for anything that matters, because a push is an accelerator, not the database.
The simulator cannot prove the behaviour you sell
Green unit tests cannot prove the phone-locked, app-terminated, months-old refusal, time-zone-change, or no-network situation that matters, and MyKiddos has focused widget-status tests with an in-memory Core Data stack for dose-gap timing, medicine-line formatting, temperature output, absence behaviour, the pending-to-ready text transition, and calendar-day episode labels. Derived state deserves those tests because a date mistake becomes a wrong Home Screen claim, but they do not prove delivery, so use a real-device checklist:
- Install fresh and confirm the app works before it asks permission.
- Deny permission, set a reminder, and confirm the app explains the missing channel without nagging.
- Allow it, schedule an alert, lock the phone, and wait for delivery.
- Edit, complete, and delete the item before its time; prove the old alert does not appear.
- Change the focused child or shared data; confirm app, widget, and remaining reminder agree.
- Change time zone and test an interval crossing midnight. Relative intervals and calendar deadlines fail differently.
- Kill the app, restart the phone if recovery behaviour matters, and verify the next scheduled state.
- Test locked-phone previews with restrictive privacy settings.
This exercises boundaries Xcode previews and a simulator hide, because a workflow that works only while the app is open is not a reminder but a toast with better branding.
Real hardware also reveals that Widget reloads are not a precision clock, presentation depends on Focus modes, summaries, and user settings, network requests can be delayed, and background work is not a promise, so an alert arriving late must be harmless and a missing alert must not hide the data - the alert gets attention, while the app tells the truth.
For a related view of how I test behaviour rather than implementation details, read tests as contracts, while my native work and current product experience are on my CV.