Android's lifecycle is a contract your app should follow. iOS's lifecycle is a contract your app must follow — with enforcement that goes beyond guidance into active termination.
On Android, a background app can keep running for a surprisingly long time. Services run in the background. Work managers schedule deferred tasks. The system is lenient about what background processes do, intervening mainly when memory is critically low (the Low Memory Killer, Android series Post 5).
iOS is different. When your app goes to the background, you have approximately five seconds to finish what you're doing. After that, your process is suspended — the kernel freezes all your threads. No CPU time. No timers firing. No network requests completing. Your app's process exists in memory but is not executing. And if memory gets tight, the process is terminated without ceremony.
These aren't recommendations. They're enforced by the operating system.
The five states
Every iOS app exists in exactly one of five states:
Not Running. The app's process doesn't exist. Either it hasn't been launched since boot, it was terminated by the system, or the user force-quit it from the app switcher. There's nothing in memory. The next launch is a cold start.
Inactive. The app is in the foreground but isn't receiving events. This is a transient state — it happens during transitions: the app is launching, the user is pulling down the notification centre, a system alert is displayed on top, or the app is moving between foreground and background. The app is visible but not interactive. This state typically lasts less than a second.
Active. The app is in the foreground, visible, and receiving events. This is the normal running state. Your Flutter engine is rendering frames, touch events are being dispatched, timers are firing. This is where your app spends most of its time while the user is using it.
Background. The app is not visible but is still executing code. This is the critical state — and the most restricted. When the user presses Home or switches to another app, your app transitions from Active → Inactive → Background. iOS gives you a brief window (approximately 5 seconds by default, extendable to about 30 seconds with beginBackgroundTask) to complete work. After that window closes, the app is suspended.
Suspended. The app's process is in memory, but no code is executing. The kernel has frozen all the process's threads. No CPU cycles are allocated. No timers fire. No callbacks execute. Future.delayed doesn't complete. Network requests don't progress. The app is, from a computational perspective, paused. It stays in this state until either the user brings it back to the foreground (it resumes almost instantly, since it's still in memory) or the system terminates it to reclaim memory.
┌──────────────┐
│ Not Running │
└──────┬───────┘
│ launch
▼
┌──────────────┐
│ Inactive │◄──── system alert, notification centre
└──────┬───────┘
│
▼
┌──────────────┐
┌────────│ Active │────────┐
│ └──────────────┘ │
│ notification │ home button /
│ centre │ app switch
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Inactive │ │ Background │ ← ~5-30 seconds
└──────────────┘ └──────┬───────┘
│ time expires
▼
┌──────────────┐
│ Suspended │
└──────┬───────┘
│ memory pressure
▼
┌──────────────┐
│ Not Running │
└──────────────┘The background execution window
When your app moves to the background, UIApplicationDelegate receives applicationDidEnterBackground:. At this point, you're on borrowed time.
The default background execution time is about 5 seconds. You can request more by calling UIApplication.beginBackgroundTask:
// In FlutterAppDelegate or a plugin
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
func applicationDidEnterBackground(_ application: UIApplication) {
backgroundTask = application.beginBackgroundTask {
// Expiration handler — called when time is almost up
application.endBackgroundTask(self.backgroundTask)
self.backgroundTask = .invalid
}
// Do essential work: save state, close connections
// You have ~30 seconds total
application.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
}This extends the window to approximately 30 seconds (the exact duration is determined by the system and has varied across iOS versions). After the expiration handler fires, your app must stop background work. If it doesn't, the system terminates the process — not suspends, terminates.
For Flutter apps, the engine handles some of this automatically. When applicationDidEnterBackground: fires, the engine:
- Stops rendering frames (no visible surface to render to)
- Sends
AppLifecycleState.pausedto the Dart side - Reduces timer frequency
But the engine doesn't stop the Dart event loop. If you have Dart timers, streams, or async operations in flight, they continue executing during the background window. When the window expires and the process is suspended, everything freezes mid-execution — a Future that hasn't completed yet simply stops. It will resume from exactly that point if the app returns to the foreground.
Flutter's lifecycle mapping on iOS
The mapping from iOS states to Flutter's AppLifecycleState:
| iOS state | UIApplication callback | Flutter AppLifecycleState |
|-----------|----------------------|--------------------------|
| Inactive (launching) | willFinishLaunching | detached → inactive |
| Active | didBecomeActive | resumed |
| Inactive (transitioning) | willResignActive | inactive |
| Background | didEnterBackground | hidden → paused |
| Suspended | (no callback — frozen) | (no state change — frozen) |
| Terminated | (no callback — killed) | (nothing — process gone) |
| Returning to foreground | willEnterForeground | hidden → inactive → resumed |
The hidden state (added in Flutter 3.13) corresponds to the moment when the app is no longer visible but hasn't fully transitioned to background. On iOS, this maps to the brief period between willResignActive and didEnterBackground.
Notice the gap: there's no AppLifecycleState for "suspended" or "terminated", because your code isn't running in those states. The last state your Dart code sees is paused. After that, either the app resumes (and you get resumed again) or the process is killed and you get nothing.
Suspension: the invisible state
Suspension is the iOS lifecycle state that has no direct equivalent on Android. When a process is suspended:
- All threads are frozen. The kernel removes the process from the scheduler. No thread gets CPU time. The process's state (registers, stack, heap) is preserved in memory, but no instructions execute.
- Timers don't fire. A
Timer.periodicorFuture.delayedthat was scheduled doesn't trigger. The timer is still registered, but the kernel isn't giving the process time to check it. - Network connections may be torn down. TCP connections that go idle for too long will be closed by the server (or by intermediate infrastructure). When the app resumes, those connections are dead. Your Dart HTTP client will get socket errors on the next request.
- Memory may be reclaimed. iOS can reclaim clean pages (code pages, memory-mapped file pages) from a suspended process without terminating it. The pages will be re-loaded from disk (page fault) when the app resumes and accesses them. This can make resumption feel slightly sluggish — the first few interactions trigger page faults as code and data are re-read from flash storage.
The suspended state is why iOS apps resume almost instantly — the process is still in memory, and "resuming" means the kernel simply starts scheduling the process's threads again. No re-launch needed, no main() re-execution, no engine re-initialisation. The Dart VM was frozen mid-execution and continues from exactly where it left off.
But this creates a subtle bug pattern in Flutter apps: stale state. If your app was suspended for 30 minutes, the Dart state reflects the world as it was 30 minutes ago. Authentication tokens may have expired. Cached data may be stale. WebSocket connections are dead. The app needs to detect that it's resuming from a long suspension and refresh its state.
DateTime? _lastActive;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_lastActive = DateTime.now();
} else if (state == AppLifecycleState.resumed && _lastActive != null) {
final elapsed = DateTime.now().difference(_lastActive!);
if (elapsed > const Duration(minutes: 5)) {
// Significant time has passed — refresh auth, reconnect, etc.
_refreshAfterLongSuspension();
}
}
}Background execution exceptions
iOS allows specific categories of apps to execute in the background beyond the normal window. Each requires a declared capability in the app's Info.plist and is subject to Apple's App Store review guidelines:
Audio. Apps playing or recording audio can run indefinitely in the background. The audio session must be active. If the audio stops, the system suspends the app.
Location. Apps using continuous location updates (navigation, fitness tracking) can run in the background. The location indicator (blue bar or blue pill) appears in the status bar, informing the user.
VoIP. Voice-over-IP apps can maintain a background connection for incoming calls using PushKit.
Background fetch. The system periodically wakes the app to fetch new content. The schedule is determined by iOS based on the user's usage patterns — there's no fixed interval. The app gets about 30 seconds per fetch.
Background processing (BGTaskScheduler). For longer background work (database maintenance, ML model updates), BGProcessingTask runs during device charging and idle periods. The app can request up to several minutes of background execution.
Remote notifications with content-available. A silent push notification can wake the app for about 30 seconds to process incoming data. This is how messaging apps update their badge count and pre-download messages.
For Flutter apps, plugins like workmanager and background_fetch wrap these iOS capabilities. They use platform channels to notify the Dart side when background execution time is available, allowing Dart code to run during the background window.
The critical constraint: all of these are time-limited and audited. Apple reviews apps that declare background capabilities and rejects those that abuse them (e.g., declaring audio background mode to keep alive an app that doesn't play audio). This is stricter than Android, where a foreground service notification is often sufficient to keep a process alive.
Termination: when suspended isn't enough
A suspended process still uses memory. The process's heap, stack, code pages, and kernel data structures all occupy physical RAM (or at minimum, occupy page table entries for pages that might be in RAM). When iOS needs that memory for the foreground app or a system service, it terminates suspended processes.
This termination is performed by Jetsam — iOS's memory management daemon (covered in depth in Post 4). The process receives SIGKILL, which is immediate and uncatchable. No applicationWillTerminate: callback. No dispose() methods. No state saving. The process ceases to exist.
This is why the advice from the Android series applies doubly on iOS: save critical state when entering the background, not when being terminated. The background transition (AppLifecycleState.paused) is your last reliable opportunity to persist state. There is no "about to be killed" notification.
The user's experience: they switch back to your app, and instead of resuming where they left off, they see a cold launch. The app restarts from main(). Whatever state they had — form input, navigation position, scroll offset — is gone unless you persisted it.
Force quit: a special case
When the user swipes your app away in the app switcher, iOS force-quits the process. This is different from system termination:
- The process receives
SIGKILL(same as system termination — no callbacks) - Silent push notifications are blocked until the user manually launches the app again
- Background fetch is disabled until the next manual launch
- The system treats the force-quit as an explicit user decision to stop the app
This means that if your app relies on silent push notifications to update content or background fetch to stay current, a force-quit disables both until the user opens the app by tapping the icon. This is intentional — Apple considers it a privacy feature. If the user explicitly killed the app, it shouldn't be waking up in the background.
The lifecycle and Flutter's engine
Flutter's engine responds to iOS lifecycle events through the FlutterAppDelegate:
Active → Inactive: The engine continues rendering. Touch events may not be delivered (e.g., during a system alert). Animations keep running.
Inactive → Background: The engine stops the rendering loop. Impeller stops submitting frames. The GPU context is preserved but idle. The Dart VM continues running (timers, async operations proceed) during the background window.
Background → Suspended: The kernel freezes everything. The engine, the Dart VM, the threads — all stop. Nothing runs.
Suspended → Inactive → Active (resume): The kernel resumes the threads. The engine restarts the rendering loop. Impeller begins submitting frames again. The Dart VM continues from where it was frozen. AppLifecycleState.resumed is sent to Dart.
The engine's rendering loop is tied to CADisplayLink (the iOS equivalent of Android's Choreographer). When the app enters the background, the CADisplayLink is invalidated — it stops firing. When the app returns to the foreground, a new CADisplayLink is created and the rendering loop restarts. This is why there's sometimes a frame skip on resume — the first frame after resumption might take slightly longer as the engine re-establishes its rendering pipeline.
Practical patterns for Flutter on iOS
Save state on `paused`, not on termination. AppLifecycleState.paused is the last state you can rely on. Persist anything the user would expect to survive across sessions: authentication state, navigation position, form drafts, scroll positions.
Detect long suspension on resume. Compare DateTime.now() with a timestamp saved when entering paused. If significant time has passed, refresh auth tokens, reconnect sockets, and re-fetch stale data.
Don't assume network connections survive. After resume, treat all network connections as potentially dead. Use connection health checks or reconnection logic in your HTTP client or WebSocket wrapper.
Respect the background time limit. If you use a plugin for background work, ensure it completes within the allotted time. An operation that takes 60 seconds in a 30-second background window will be killed mid-execution, potentially leaving data in an inconsistent state. Use transactions for database operations so incomplete work is rolled back.
Test the suspension path. In Xcode, the Debug menu has "Simulate Background Fetch" and you can use Instruments to observe the suspension/termination lifecycle. On a real device, switch to other apps and use the Memory report to watch your app's state change from "running" to "suspended" to "terminated."
iOS vs Android lifecycle: the key difference
The fundamental difference isn't in the states themselves — both platforms have foreground, background, and terminated states. The difference is in what happens between background and terminated.
On Android, a background process keeps running. It uses CPU time. It can schedule work, respond to broadcasts, run services. The system only intervenes when memory is critically low, and even then, it kills by priority (the Low Memory Killer's oom_adj scoring).
On iOS, a background process is suspended within seconds. It uses zero CPU time. It can't schedule work, respond to events, or do anything at all. The system terminates suspended processes whenever it needs memory, and it needs memory often because there's no swap.
This makes iOS's memory management more aggressive (Post 4 covers this in depth), but it also makes the system more power-efficient. A suspended app uses zero battery. An Android app with a background service uses battery continuously. Apple chose strict lifecycle enforcement to protect battery life and system responsiveness, at the cost of limiting what background apps can do.
For Flutter developers, this means the same Dart code behaves differently on the two platforms. A Timer.periodic that fires every 30 seconds will fire reliably on Android (as long as the process is alive) but will stop completely on iOS once the app is suspended. Background-aware Flutter code needs to account for this platform difference.
The next post examines iOS's memory management in detail — Jetsam, the no-swap policy, and the memory warnings that give your app a last chance before termination.
This is Post 3 of the iOS Under the Surface series. Previous: App Launch: From Tap to First Frame. Next: Memory: Jetsam, Compression, and No Second Chances.