An app is not running, inactive (on screen but not receiving events), active (on screen and receiving events), in the background (off screen, still running code) or suspended (in memory, running nothing). Launches, interruptions, leaving the app and memory pressure move it between them.
Launch: not running → inactive → active. A launch for a silent push or a BGTaskScheduler task goes straight to the background and shows no UI.
Interruption: Control Center, a system alert or a Face ID prompt: active → inactive and back, without reaching the background.
Leaving: active → inactive → background. Seconds later, or when its background task ends, the system suspends the app, and no callback reports it.
Coming back: suspended → background → inactive → active, with memory exactly as it was.
Termination: under memory pressure the system kills suspended apps silently, so the next launch is cold. applicationWillTerminate runs only for an app killed while still running.
iOSMiddle
App Lifecycle, Background & Push
Cold launch, scene phases, background modes, BGTaskScheduler, APNs, silent push, universal links
Questions closed
0 / 23
- 0Review
- 3Asked often
Questions
AppDelegate handles the process: launch, push tokens, background URLSession events, choosing a scene configuration. Each SceneDelegate owns one window and its foreground and background lifecycle. iOS 13 split them so that one app can show several windows, each in its own state.
**AppDelegate**: didFinishLaunching runs once per process, with or without UI, so SDK setup, BGTaskScheduler registration and the notification delegate go there.
**SceneDelegate**: scene(_:willConnectTo:options:) creates the UIWindow and receives the link that launched the scene. It may run many times per process, because the system disconnects background scenes and reconnects them later.
Why scenes: on iPad two windows can be in different states, and one app-wide applicationDidEnterBackground cannot say which one left. With scenes UIKit stops calling the app delegate's lifecycle methods; the notifications still post.
Mandatory: per Apple's TN3187, in the release after iOS 26 an app built with the latest SDK must adopt scenes or it will not launch; one scene in UIApplicationSceneManifest is enough.
Read @Environment(\.scenePhase) and react in .onChange(of:): .active, .inactive and .background match the UIKit scene states, and @UIApplicationDelegateAdaptor brings back an AppDelegate for the callbacks SwiftUI has no modifier for.
Where you read it matters: inside a view, scenePhase is that window's phase. In the App body it is an aggregate: .active if any scene is active, .background only when all of them are.
**.inactive is not leaving**: it also appears during a Face ID prompt or a Control Center pull-down, so save on .background, not on .inactive.
There is no suspended phase: as in UIKit, nothing reports suspension or termination; .background is the last guaranteed signal.
**@UIApplicationDelegateAdaptor**: still needed for the APNs token callbacks, early BGTaskScheduler registration of processing tasks and other UIApplicationDelegate hooks SwiftUI does not expose. For app refresh and background URLSession events, the .backgroundTask(.appRefresh(_:)) and .backgroundTask(.urlSession(_:)) scene modifiers replace the manual hooks.
No. It runs only if the app is terminated while still executing, and most apps die while suspended, when the system kills them with no callback at all. Save in sceneDidEnterBackground (or on scenePhase == .background), the last moment you are guaranteed to run.
Jetsam: under memory pressure the kernel kills suspended apps first, silently. The user comes back hours later to the launch screen and an empty form, and calls it a crash.
Force-quit from the app switcher: the app is usually suspended at that point, so again there is no callback.
Save as you go: write drafts while the user types or on backgrounding, and restore from disk in scene(_:willConnectTo:options:), never from objects you hope are still in memory.
Measure it: MetricKit's MXAppExitMetric splits background exits into memory-limit kills, watchdog kills and normal exits, so you know how often it really happens.
A local notification is scheduled by the app on the device with a trigger; a remote one is sent by your server through APNs to a device token. Both look the same to the user and share one permission, requested with UNUserNotificationCenter.requestAuthorization(options:).
Local: a UNNotificationRequest with a time, calendar or location trigger. Works offline and needs no server; the system keeps at most 64 pending ones per app.
Remote: needs the Push Notifications capability, a device token from registerForRemoteNotifications() and a server that talks to APNs. Its content can change after the app has shipped, and it reaches users who have not opened the app.
One prompt, once: the system dialog appears only the first time. After a denial the only way back is Settings (UIApplication.openSettingsURLString), so ask at a moment when the user understands why.
The token needs no permission: registerForRemoteNotifications() works without alert permission, which is what silent pushes rely on.
sceneDidBecomeActive fires again after every interruption that only makes the app inactive (a Face ID prompt, Control Center, a system alert), so it logs extra starts with no matching end. Start a session in sceneWillEnterForeground, the counterpart of sceneDidEnterBackground.
Interruptions stay in the foreground: they go sceneWillResignActive → sceneDidBecomeActive without touching the background pair, so foreground and background entries are the symmetric signal.
Cold launch and resume both pass through it: on resume only sceneWillEnterForeground and sceneDidBecomeActive run; on a cold launch the scene connects first and then the same two follow, so one hook covers both.
**Do not count in didFinishLaunching**: the system also launches the process into the background for a silent push or a BGTaskScheduler task, with no user and no UI.
Use an idle gap: analytics SDKs end a session after a timeout of several minutes (Firebase defaults to 30 minutes, Amplitude to 5), so store the time the app left and compare it with the return.
SwiftUI has the same trap: scenePhase == .active fires after every interruption too. Remember the move to .background in a flag and start a new session on the next .active only if the flag is set, then clear it.
The system creates the process, dyld loads and links every dynamic library, the Objective-C runtime and static initialisers run, and then main calls UIApplicationMain, which creates UIApplication and your app delegate and only then calls willFinishLaunching and didFinishLaunching.
Before main
SpringBoard asks the system to launch the app and immediately shows the launch screen from UILaunchScreen or the launch storyboard: a static snapshot, none of your code.
The kernel maps the executable, and dyld loads every dynamic framework, applies rebases and binds (chained fixups on current toolchains) and registers Objective-C classes and categories.
Initialisers run: +load methods, C++ static constructors, __attribute__((constructor)) functions. Swift globals and static let are lazy and do not run here.
After main
UIApplicationMain creates UIApplication, instantiates the delegate class, reads Info.plist and calls willFinishLaunchingWithOptions, then didFinishLaunchingWithOptions. In SwiftUI, @main on the App type generates the same main.
With scenes it then asks application(_:configurationForConnecting:options:), connects the scene in scene(_:willConnectTo:options:), and launch ends when the first frame is on screen.
Why it matters
Pre-main time grows with the number of dynamic frameworks and with work in +load and constructors, so fewer or static libraries and empty initialisers make launch faster.
If launch takes too long the watchdog kills the app with 0x8badf00d; the limit is not documented and is commonly cited as about 20 seconds, but the practical target is a first frame in well under a second.
After the user leaves, an app gets a few seconds in the background and is then suspended. Anything longer needs a sanctioned mechanism: a background task, a BGTaskScheduler task, a background URLSession, a push, or a background mode used for its stated purpose.
The sanctioned tools
**beginBackgroundTask: finish what you already started, such as a save or one request: seconds, not minutes.
BGAppRefreshTask / BGProcessingTask: deferrable work the system runs when it chooses, based on usage, battery and network.
Background URLSession: transfers run in a system daemon and survive suspension; the app is relaunched when they finish.
Silent push: a server hint that wakes the app for about 30 seconds, throttled by the system.
BGContinuedProcessingTask (iOS 26)**: work the user started, like an export, that keeps running with a system progress UI.
Background modes
UIBackgroundModes in Info.plist keeps the app running only for a specific job: audio, location, voip, bluetooth-central, external-accessory. The fetch, processing and remote-notification modes only allow the system to wake you.
Declaring a mode you do not genuinely use (audio to keep a sync alive is the classic case) is rejected under App Review guideline 2.5.4.
What still stops you
Low Power Mode, Background App Refresh turned off and a force-quit stop most wake-ups, and the watchdog and jetsam enforce time and memory limits. Design for "it may never run": sync again on the next foreground.
There is no expiration handler and the task is ended only on success, so a slow or failed upload leaves the identifier open; when the budget runs out, the watchdog kills the app with 0x8badf00d, which users see as a crash on switching away.
The budget is short: around 30 seconds on current iOS, not minutes. Read UIApplication.shared.backgroundTimeRemaining instead of assuming a number.
The expiration handler is required: it runs on the main thread just before time is up and must cancel the work and call endBackgroundTask synchronously.
End exactly once, on every path: success, failure and expiry. Reset the stored identifier to .invalid so a second call does nothing.
The system can refuse: beginBackgroundTask may return .invalid; do not start work that depends on it.
Wrong tool for big uploads: a background URLSession upload keeps going after suspension and relaunches the app when it finishes.
The view counts ticks instead of measuring time. In the background the app is suspended within seconds and the timer stops firing, so every minute spent away is lost and the workout comes back short by exactly that time.
Why: a suspended process runs no code, so neither Timer nor Timer.publish fires. On resume a repeating timer fires once for all the intervals it missed instead of catching up, so elapsed += 1 adds one second for a ten-minute absence. Locking the screen does the same.
The fix: keep the start Date and derive the elapsed time from the clock on every tick. The timer now only triggers a redraw, so a missed tick no longer loses time. Keep startedAt in the model and persist it: @State is gone if the system kills the suspended app.
No timer at all: Text(startedAt, style: .timer) counts by itself, and TimelineView(.periodic(from:by:)) redraws on a schedule; both compute from dates.
Same trap elsewhere: OTP resend countdowns, session timeouts and "try again in 30 s" screens break the same way. Store a deadline, not a counter; for a limit the user must not bypass by changing the device clock, check it against server time.
Catch it: on a device, start the timer, lock the phone for a minute and come back. In a unit test, inject the clock and assert the value comes from it, not from the number of ticks.
Register a handler for the identifier before didFinishLaunching returns, list the identifier in Info.plist, then submit a BGAppRefreshTaskRequest or BGProcessingTaskRequest. The system decides when to run it, and a few silent setup mistakes mean it never does.
Which request
**BGAppRefreshTask**: about 30 seconds to fetch fresh content before the user opens the app. Needs the fetch background mode.
**BGProcessingTask**: minutes, usually overnight while the device is charging and idle; can require external power or network. Needs the processing mode.
Why it never fires
The identifier is missing from BGTaskSchedulerPermittedIdentifiers: submit throws .notPermitted, and try? hides it for a whole release.
Registering after didFinishLaunching has returned raises an NSInternalInconsistencyException, so register synchronously there, never in a view or after an await.
The handler never submits the next request, so the task ran once and never again.
setTaskCompleted(success:) is not called before the expiration handler fires: the app is killed and deprioritised next time.
earliestBeginDate is a floor, not a schedule. An app the user rarely opens is rarely woken, and Background App Refresh off, Low Power Mode or a force-quit stop it entirely.
The Simulator never runs these tasks.
Log pendingTaskRequests() at launch to see what the system actually holds, and run the same sync on the next foreground anyway.
On a device, background the app after it has submitted the request, pause in Xcode and call the private _simulateLaunchForTaskWithIdentifier: from LLDB; the registered handler runs immediately. _simulateExpirationForTaskWithIdentifier: fires the expiration handler the same way.
Device only: the Simulator does not run BGTaskScheduler tasks at all.
A debugging aid: these are private methods that exist for exactly this; never ship a call to them.
What it does not prove
That the system would schedule you: earliestBeginDate, the power and network requirements, the on-device usage model and Low Power Mode are all bypassed.
The time budget: with the debugger attached the watchdog is off, so a handler that takes too long passes here and dies in the field.
A cold background launch: the process is already warm, so launch code that assumes a UI stays untested.
So verify the code path with LLDB and the scheduling separately: log inside the handler with Logger and check Console, or your own uploaded logs, after a real overnight run.
The app asks APNs for a device token and sends it to your server; the server posts a JSON payload for that token to APNs over HTTP/2, authenticated with a .p8 key or a certificate, and APNs delivers it so iOS can show it or wake the app.
On the device
registerForRemoteNotifications() leads to application(_:didRegisterForRemoteNotificationsWithDeviceToken:) with opaque Data. Failures arrive in didFailToRegisterForRemoteNotificationsWithError and usually mean a missing entitlement.
Hex-encode the bytes yourself: since iOS 13 deviceToken.description returns {length = 32, bytes = ...}, not the hex string.
The token changes on reinstall, on restore to a new device and when the system decides, so register on every launch and upsert it on the server.
On the server
Send to api.push.apple.com for App Store and TestFlight builds and api.sandbox.push.apple.com for development builds; mixing them up gives 400 BadDeviceToken.
Headers: apns-topic (the bundle id), apns-push-type (alert, background and others), apns-priority, and optionally apns-expiration and apns-collapse-id.
Payload: the aps dictionary (alert, badge, sound, content-available, mutable-content, category) plus your own keys, up to 4 KB.
410 Unregistered means the app is gone from that device: delete the token instead of retrying.
Key tokens by user and device and remove the pair on logout, or the next person to sign in gets the previous user's notifications.
The payload needs "content-available": 1 and nothing visible, the request needs apns-push-type: background and apns-priority: 5, and the app needs the remote-notification background mode. Even then the system treats it as a hint and may delay or drop it.
What it requires
The aps dictionary holds only content-available. Adding alert, sound or badge turns it into an ordinary notification.
Priority 10 is not allowed for a background-only push; use 5.
Implement application(_:didReceiveRemoteNotification:fetchCompletionHandler:) or its async form, and finish within about 30 seconds with an honest UIBackgroundFetchResult.
Why it still does not arrive
The system budgets background wake-ups per app; Apple advises no more than two or three such pushes per hour, so one push per data change gets throttled.
Low Power Mode, Background App Refresh turned off, or a force-quit from the app switcher stop delivery to the app.
While a device is offline APNs keeps only the latest notification per app, and apns-expiration: 0 means "deliver now or drop".
Treat it as a hint that something changed, not as a delivery channel: sync when woken, and sync again on the next foreground.
completionHandler(.newData) runs right after the Task is created, before the sync has done anything. It tells iOS the work is finished, so the app is suspended soon after and the request freezes mid-flight; the inbox catches up only on the next foreground.
Why: Task { } only schedules the work and returns, so the next line runs immediately. The completion handler is the app's only way to say "done", and after it the system is free to suspend the process.
The fix: call the handler inside the Task, after sync() returns, exactly once on every path: .newData or .noData from the real result, .failed on error.
Prefer the async form: with application(_:didReceiveRemoteNotification:) async -> UIBackgroundFetchResult the app is done only when the function returns, so this mistake is much harder to make: just await sync() before returning.
The opposite bug: never calling the handler. The budget is about 30 seconds, after which the app is killed, so give the sync its own timeout well under that. The system also tracks the time and power each wake-up costs, and slow handlers get fewer wake-ups later.
Catch it: log the start and the end of the sync with Logger, then send a background push from the Push Notifications Console to a device with the app in the background. An end line that shows up only after you open the app is this bug.
Implement userNotificationCenter(_:willPresent:) to decide whether a foreground notification is shown, and register a UNNotificationCategory with actions at launch; the payload's category key picks the buttons, and userNotificationCenter(_:didReceive:) handles taps and actions.
Where the buttons come from
UNNotificationAction(identifier:title:options:) is one button. With .foreground it opens the app; without it your handler runs in the background for a few seconds. UNTextInputNotificationAction adds an inline reply field.
Group actions into a UNNotificationCategory, register them once with setNotificationCategories(_:), and send "category": "MESSAGE" inside aps.
The two delegate callbacks
willPresent runs only while the app is in the foreground. Return [] to hide a notification for the chat already on screen, or [.banner, .list, .sound] otherwise; .alert was replaced by .banner and .list in iOS 14.
didReceive runs for every tap and action, foreground or not. actionIdentifier is UNNotificationDefaultActionIdentifier for a tap on the body, and typed text arrives as UNTextInputNotificationResponse.userText.
The trap
Set UNUserNotificationCenter.current().delegate before didFinishLaunching returns. When a tap launches the app, the response is delivered right after launch; if the delegate is still nil, it is lost and the app opens on its default screen. Routing belongs in didReceive, never in willPresent.
Adding .provisional to requestAuthorization grants permission without any prompt, but notifications go quietly to Notification Center, with no banner or sound, until the user taps Keep. And .authorized only means the app may post, not that banners are switched on.
The trial: each quiet notification carries Keep and Turn Off buttons. Keep lets the user choose Deliver Prominently or Deliver Quietly; Turn Off moves it to .denied, and from there only Settings can undo it.
Push still works: registerForRemoteNotifications() returns a real token, so APNs delivery is genuine, just muted.
The statuses: .notDetermined (you may still ask), .provisional, .authorized, .denied (send the user to UIApplication.openSettingsURLString), and .ephemeral for App Clips.
Authorised is not visible: UNNotificationSettings has a separate value per surface: alertSetting, soundSetting, lockScreenSetting, notificationCenterSetting, and scheduledDeliverySetting for Scheduled Summary. Focus modes can hold notifications back too. Read these before blaming APNs.
A pattern that works: request provisionally on first launch, earn the promotion with useful notifications, and show the real prompt only when the user asks for something that needs it.
A custom scheme like myapp:// is unverified (any app can claim it and it fails when the app is not installed), while a universal link is an ordinary https URL that opens the app only after iOS verifies the domain, and opens the website otherwise.
What a universal link needs
An apple-app-site-association file at https://<domain>/.well-known/: JSON over HTTPS with no redirect, listing TEAMID.bundleid under applinks.details with components that match the path.
The Associated Domains entitlement applinks:example.com in the provisioning profile the build is actually signed with. A missing entitlement fails silently and looks like a bad file.
Since iOS 14 devices fetch the file through Apple's CDN, which caches it for a day or more. applinks:example.com?mode=developer bypasses the cache on a device with associated-domains development enabled in Developer settings.
Why it opens Safari instead
A link to the same domain tapped on a page from that domain, or typed into the address bar, stays in Safari.
The user once chose to open the site in Safari from the banner, and that choice sticks per domain.
Settings → Developer → Universal Links → Diagnostics checks a URL on the device.
Custom schemes still fit app-to-app callbacks such as an OAuth redirect in ASWebAuthenticationSession. Treat any incoming URL as untrusted input.
On a cold launch the link does not come through scene(_:continue:): it arrives in connectionOptions.userActivities inside scene(_:willConnectTo:options:), and this code ignores it.
Two paths, one handler: send the cold path (connectionOptions) and the warm path (scene(_:continue:)) to the same routing function.
Custom schemes split the same way: connectionOptions.urlContexts on a cold launch, scene(_:openURLContexts:) while running.
Notification taps: have the same cold/warm split (see the notification delegate question).
SwiftUI: .onOpenURL receives links on both paths, so this bug is mostly a UIKit one.
Test the cold path on purpose: kill the app, then tap the link in Notes or Messages. Running from Xcode keeps the app warm and hides the bug.
A silent push, a BGTaskScheduler task, a finished background URLSession, or a location or Bluetooth event can start the process with no UI. The same didFinishLaunching runs, but no scene connects and no UI is shown.
How to tell
A background launch has no scene: scene(_:willConnectTo:options:) never runs, and launchOptions carries the reason (.remoteNotification, .location, .bluetoothCentrals) or is empty for a BGTaskScheduler wake. Do UI-dependent and heavy setup from scene(_:willConnectTo:options:) or sceneWillEnterForeground, not from didFinishLaunching.
In a SwiftUI app the @UIApplicationDelegateAdaptor delegate gets the same callback.
What the path must avoid
Touching UI: there is no window, and reaching into view code here produces crash reports with no user in sight.
Reading protected data before first unlock: files with NSFileProtectionComplete and Keychain items with kSecAttrAccessibleWhenUnlocked fail with errSecInteractionNotAllowed. Check isProtectedDataAvailable.
Bootstrapping everything: every SDK, cache warm-up and migration spends battery on work nobody sees, and the budget is seconds.
Counting an app open: analytics in didFinishLaunching invents launches that never happened.
What it must do
Register BGTaskScheduler handlers and set the notification delegate on every launch, do the one job, and call its completion handler; skipping that gets future wake-ups throttled. Keep the work idempotent and cancellable, because the same wake can repeat and the process can be suspended mid-way.
A force-quit tells iOS the user wants the app stopped: silent pushes, BGTaskScheduler tasks, background URLSession transfers and location relaunches stop until the user opens it again, while alert pushes and VoIP pushes still get through.
Stopped until the next manual launch
Silent pushes with content-available are not delivered to the app.
BGAppRefreshTask and BGProcessingTask do not run; submitted requests just sit there.
Background URLSession transfers are cancelled by the system.
Significant-location, region and visit events do not relaunch an app the user terminated; the user must open it again (relaunch works only for apps the system itself terminated).
Still delivered
Alert pushes: the system draws them, the notification service extension still runs in its own process, and a tap launches the app normally. Already scheduled local notifications fire too.
PushKit VoIP pushes: on condition that every one is reported to CallKit as an incoming call; otherwise iOS kills the app and eventually stops delivering them.
Why it matters
Testers force-quit out of habit and then report that background sync never works; reproduce with an app the system suspended or reclaimed instead. And never let data freshness depend on silent push alone: one swipe turns it off for that user, so the app must also reconcile on every foreground.
Since iOS 15 the system may start your process ahead of a predicted launch, run dyld and static initialisers, and then pause before UIApplicationMain. A launch timer anchored at process start then counts minutes the user never waited.
What runs at prewarm
Dynamic linking, +load methods and C++ static constructors run on the system's schedule, possibly with the phone in a pocket.
UIApplicationMain, didFinishLaunching, scene connection and the first frame all wait for the real tap. Swift globals and static let are lazy, so they run only if something at prewarm touches them.
Measuring correctly
Detect it: ProcessInfo.processInfo.environment["ActivePrewarm"] == "1", and bucket those samples separately instead of averaging them in.
Start the clock in didFinishLaunching, which never runs during prewarm, and stop it on the first rendered frame, not at the process start time read from sysctl.
In the field, trend MetricKit's MXAppLaunchMetric.histogrammedOptimizedTimeToFirstDraw, which already accounts for prewarming.
Side effects
Anything in +load or a constructor that opens a database, stamps a session id or fires a request now does it at a random moment, possibly long before the user exists. Keep pre-main work empty and move setup to didFinishLaunching or later.
It lets work the user explicitly started (an export, an upload, a large edit) keep running after they leave the app, with a system progress UI they can watch and cancel. Unlike BGProcessingTask, it starts now, from the foreground, rather than whenever the system chooses.
How it works
Submit a BGContinuedProcessingTaskRequest(identifier:title:subtitle:) from the foreground, in direct response to a user action. The permitted identifier may end in a wildcard (com.example.app.export.*) so each job gets its own id, and the handler can be registered right before submitting rather than only at launch.
The handler receives a BGContinuedProcessingTask. Update task.progress as you go and the text with updateTitle(_:subtitle:); a task that stops reporting progress can be expired.
request.strategy = .fail makes the submission fail when the system cannot start it right away; the default .queue lets it wait.
The user can cancel from the system UI, which calls expirationHandler. Every path ends with setTaskCompleted(success:).
When not to use it
Maintenance, sync or prefetch the user did not ask for belongs in BGProcessingTask or BGAppRefreshTask.
A single network transfer is better as a background URLSession, which already survives suspension.
GPU work in the background needs the Background GPU capability and requiredResources = .gpu; check BGTaskScheduler.shared.supportedResources first, because a request for an unavailable resource is rejected at submit.