App Lifecycle, Background & Push
Cold launch, scene phases, background modes, BGTaskScheduler, APNs, silent push, universal links
- 01
Analytics says sessions doubled after a release nobody shipped analytics changes in — which lifecycle callback is the counter hooked to, and which one should it be?
EasyA counter wired to
sceneDidBecomeActivefires again after every Face ID prompt, Control Center swipe and app-switcher peek, so it is counting interruptions; the transition you want issceneWillEnterForeground.final class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? // Cold launch only — the process was not alive before this func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { … - 02
A user comes back two hours later and the app starts from the launch screen with the draft gone — what killed it, and did you get a callback?
EasyAlmost certainly jetsam: the kernel reclaimed your suspended process under memory pressure, and a suspended app gets no callback at all, so anything not written down by
sceneDidEnterBackgroundwas lost before the user ever came back.final class SceneDelegate: UIResponder, UIWindowSceneDelegate { // The last guaranteed moment. There is no callback after suspension. func sceneDidEnterBackground(_ scene: UIScene) { try? draftStore.write(editor.currentDraft) // survives a jetsam kill UserDefaults.standard.set(router.path, forKey: "lastRoute") … - 03
You call
beginBackgroundTaskto finish an upload after the user leaves — how much time do you actually get, and what happens if nothing ever ends it?EasyYou get seconds, not minutes, and an identifier that is never ended gets the process killed by the watchdog with exception code
0x8badf00d— which users experience as "the app crashes when I switch away".// WRONG: ends the task only on the happy path, and only from a callback func uploadOnBackground() { let id = UIApplication.shared.beginBackgroundTask(withName: "upload") api.upload(draft) { _ in UIApplication.shared.endBackgroundTask(id) // never runs if the request stalls } … - 04
Product wants the app to keep syncing after the user closes it, so someone adds the audio background mode to keep the process alive — what do you tell them?
MediumThat it is a guaranteed rejection under App Review guideline 2.5.4: a background mode may only be used for the purpose it names, and declaring
audiowithout playing audible content the user asked for is the textbook example Apple cites.<!-- Info.plist — declare only what the app genuinely does --> <key>UIBackgroundModes</key> <array> <!-- Required for BGAppRefreshTask (short content refreshes) --> <string>fetch</string> <!-- Required for BGProcessingTask (longer, charger-friendly work) --> … - 05
A nightly sync is registered with
BGTaskSchedulerand users report it has never once run — walk me through what you check?MediumCheck the three things that silently disable it before you blame the system: registration that happens after
didFinishLaunchinghas returned, an identifier missing fromBGTaskSchedulerPermittedIdentifiers, and a handler that never submits the next request.// 1. Register BEFORE didFinishLaunching returns, once per identifier func application(_ app: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { BGTaskScheduler.shared.register( forTaskWithIdentifier: "com.example.app.nightly-sync", using: nil // nil = a system-provided queue … - 06
How do you watch your
BGProcessingTaskhandler actually run today, instead of leaving a device on a charger overnight and hoping?MediumPause the debugger once the request has been submitted and launch the task by hand from LLDB —
_simulateLaunchForTaskWithIdentifier:calls your registered launch handler immediately.import BackgroundTasks import os private let logger = Logger(subsystem: "com.example.app", category: "bgtask") func handleSync(_ task: BGProcessingTask) { … - 07
The push token is uploaded once at sign-up, and months later a slice of users silently stops receiving notifications — what is wrong with that flow?
MediumA device token is neither an identity nor permanent — it changes on reinstall, on restore to a new device, when the system invalidates it, and between the sandbox and production APNs environments — so it has to be re-uploaded on every launch, not once.
func application(_ app: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Every launch, not just the first one — the token may have changed UIApplication.shared.registerForRemoteNotifications() // Alert permission is a separate ask; a silent push does not need it … - 08
Your backend fires a
content-availablepush on every data change and the app never wakes up — where do you start looking?MediumStart with the headers, not the payload: a background push must be sent with
apns-push-type: backgroundandapns-priority: 5, and APNs rejects priority 10 on a background push withBadPrioritybefore it ever reaches the device.// The payload and headers the server must send: // // :path /3/device/<token> // apns-push-type background <- required; alert here means no background wake // apns-priority 5 <- 10 is rejected with BadPriority // apns-topic com.example.app … - 09
A push must arrive already decrypted and with the sender's avatar attached — which extension does that work, and what does its budget force on you?
MediumThe notification service extension is the only place that can rewrite an incoming payload before iOS shows it, so decryption and attachment downloads live there; the content extension only draws custom UI after the user expands a notification whose text is already decided.
import UserNotifications final class NotificationService: UNNotificationServiceExtension { private var handler: ((UNNotificationContent) -> Void)? private var bestAttempt: UNMutableNotificationContent? … - 10
A message push arrives while the user is already looking at that chat — what does your notification delegate do, and where do the Reply and Mute buttons come from?
MediumNothing is shown while the app is frontmost unless
userNotificationCenter(_:willPresent:withCompletionHandler:)says so, and the buttons come from aUNNotificationCategoryyou register at launch and name in the payload'scategorykey.// 1. Register categories once, at launch — before any notification can arrive. func application( _ app: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { let reply = UNTextInputNotificationAction( … - 11
Product wants notifications working on day one with no permission popup — what does provisional authorisation give you, and where do those notifications land?
MediumAdding
.provisionalto the options makesrequestAuthorizationreturntruewithout ever showing a prompt, in exchange for quiet delivery: the notifications go straight into Notification Center with no banner, no sound and no lock-screen alert.import UserNotifications let center = UNUserNotificationCenter.current() // Day one: no prompt at all. Notifications are delivered quietly to Notification Center. let granted = try await center.requestAuthorization( … - 12
A universal link opens Safari instead of your app on a tester's phone — what do you check, and in what order?
MediumStart at the association file:
https://<domain>/.well-known/apple-app-site-associationmust return JSON over HTTPS with no redirect, no query string and no authentication, and it must list yourTEAMID.bundle.idagainst a path that matches the link.// The file, served at https://example.com/.well-known/apple-app-site-association // Content-Type: application/json, HTTPS, no redirect, no query, no auth. // // { // "applinks": { // "details": [{ … - 13
An iPad user keeps three windows of your app open and the system reclaims two overnight — what does each window need to rebuild itself from nothing?
MediumOne
NSUserActivityper scene, holding identifiers rather than objects — that is the only thing the system preserves for a disconnected scene, and everything that was on screen has to be reconstructible from it.// UIKit: one activity per scene, carrying ids only. final class SceneDelegate: UIResponder, UIWindowSceneDelegate { private let router = Router() func scene(_ scene: UIScene, willConnectTo session: UISceneSession, … - 14
Your launch dashboard shows cold launches taking four minutes, and the process started long before the user tapped anything — what is going on?
HardThe system is prewarming your app: since iOS 15 it starts the process ahead of a predicted launch, runs dyld, the Objective-C runtime and your static initialisers, then stops just short of
UIApplicationMainand leaves the process sitting there — so process start time can be minutes before the tap.import OSLog // WRONG: the clock starts when the process starts, which may be a prewarm minutes early. let processStart = CFAbsoluteTimeGetCurrent() // file-scope global: runs at prewarm final class Analytics { static let shared = Analytics() … - 15
A crash log shows your app touching UIKit with no window, minutes after the user last used it — how did it get launched, and what must that path avoid?
HardThe system relaunched it straight into the background — a silent push, a
BGTaskSchedulertask, a finished backgroundURLSession, a location or Bluetooth event — and the very sameapplication(_:didFinishLaunchingWithOptions:)runs, only withapplicationState == .backgroundand, usually, no scene ever connected.func application( _ app: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { // Same method, no user. Everything below the guard is for a real, visible launch. let isBackgroundLaunch = app.applicationState == .background … - 16
The user swipes your app out of the app switcher — after that, which pushes, background tasks and location wake-ups still reach it, and which never will?
HardA swipe in the app switcher marks the app as user-terminated, and from then until the user opens it again or the device reboots, iOS refuses to relaunch it for almost every background trigger.
// Scheduled before the swipe. The request survives; nothing will launch you to run it. func scheduleRefresh() { let request = BGAppRefreshTaskRequest(identifier: "com.example.app.refresh") request.earliestBeginDate = .now.addingTimeInterval(15 * 60) try? BGTaskScheduler.shared.submit(request) // silently never runs after a force-quit } …