Mobile app lifecycle: background, suspension, process death and restore
What happens to a mobile app when the user leaves it, what may it still do, and how does it come back after the OS has killed it?
Leaving an app is a countdown: a few seconds of background time, then suspension (iOS) or a cached process (Android), then a silent kill when memory is needed. Apps that feel reliable save at the moment they go to the background, restore lightweight UI state after process death, and treat every return to the foreground as a resync.
THE MENTAL MODEL
Think of the OS as a landlord who can evict you without notice. While you are visible you own the CPU. The moment the user leaves you get one callback and a short grace period, and you must assume that is the last code you will ever run in this process: after it the app is frozen, and a frozen app can be killed with no further callback. The user never sees the kill, because the app switcher still shows your snapshot, so they expect to return to exactly where they were. That expectation is met by two different stores: small UI state the OS keeps for you (Android's saved instance state, iOS scene storage), restored only after a system-initiated kill, and durable data you persist yourself. Background work beyond the grace period is never free: it has to be requested through a platform API (BGTaskScheduler, WorkManager, foreground services, background URLSession, push) and the OS decides when, or whether, it runs. React Native adds one twist: after process death the JS runtime boots from scratch, so any JS state you did not persist is simply gone.
HOW IT FITS TOGETHER
| iOS (UIScene / SwiftUI) | Android (Activity / process) | React Native AppState | What your code may do | |
|---|---|---|---|---|
| Foreground, interactive | foregroundActive; scenePhase .active | Activity RESUMED; foreground process | active | Anything, but keep the main thread under one frame of work |
| Visible, not focused | foregroundInactive: Control Center, call, app switcher | STARTED/PAUSED: dialog on top, unfocused multi-window | inactive (iOS) or blur event (Android) | Keep rendering; pause input-dependent work such as a camera capture |
| Background, still running | background: about 5 s, more via beginBackgroundTask | STOPPED; process drops to background, then cached | background | Finish saves and in-flight requests; schedule anything longer |
| Suspended / cached | Suspended in memory, no CPU, no callback on entry | Cached process; may get little or no CPU (Android 13+) | No event fires | Nothing: timers, JS and sockets are frozen; servers time you out |
| Killed by the system | Silent; scene disconnected or process reclaimed | Low-memory kill; onDestroy is not guaranteed | No event; next open is a cold start | Restore UI state from the OS, data from your own storage |
| Killed by the user | Swiped away: no relaunch for silent pushes until reopened | Swiped from Recents: task and saved state are gone | No event | Start fresh; only persisted data survives |
| Allowed background work | BGAppRefresh/BGProcessing, background URLSession, push | WorkManager, typed foreground services, FCM | None directly; use native modules | Ask the OS; it decides timing based on battery, usage and quotas |
- User leaves the appiOS: sceneWillResignActive then sceneDidEnterBackground (scenePhase .background). Android: onPause then onStop. React Native: AppState changes to background.
- Short grace periodFlush drafts and queued writes now. iOS gives about 5 seconds unless you hold a beginBackgroundTask assertion; Android captures saved instance state (rememberSaveable, SavedStateHandle) when the activity stops.
- Suspended or cachedNo CPU. Timers stop, WebSockets are dropped by servers and middleboxes, and access tokens keep expiring. Only OS-scheduled work or a push can wake the app.
- Killed to reclaim memoryNo callback on either platform. Android keeps the task and its saved state Bundle outside your process; iOS keeps scene restoration data such as @SceneStorage.
- User returns from switcherThe switcher showed a snapshot, so the user expects continuity. Deep links or notification taps may also be the launch reason.
- Cold start with restoreAndroid recreates the activity with savedInstanceState and rebuilds the ViewModel's SavedStateHandle; iOS reconnects the scene. A React Native bundle starts from zero: restore navigation and form state from storage.
- Resync before trusting stateRefresh auth, reconnect sockets, refetch screen data and replay the offline queue. Treat restored state as a hint of where the user was, not as current data.
KEY TERMS
- App vs scene lifecycle (iOS)
- Since iOS 13 each scene (a window of your UI) has its own lifecycle: unattached, foreground inactive, foreground active, background, suspended. The app delegate still handles launch and process-level events. Building with the iOS 27 SDK makes the UIScene lifecycle mandatory; React Native 0.88 added single-scene SceneDelegate support for this.
- Activity vs process lifecycle (Android)
- Android never kills an activity to free memory; it kills the whole process. Kill priority follows process importance: foreground, visible, service, then cached processes, which are killed first and may already be frozen. ViewModels survive rotation, not process death.
- System-initiated vs user-initiated dismissal
- Saved instance state (rememberSaveable, SavedStateHandle) and iOS scene storage are restored after the system kills the app, because the user still expects to come back. After the user swipes the app away, force-stops it or presses Back to finish it, that state is discarded on purpose.
- Saved state vs persistence
- Saved state is for small, transient UI values: the selected ID, a query, a scroll position, an unfinished text field. It is serialized on the main thread into a Bundle, so large data belongs in a database or file. Rule of thumb: persist what the user would be angry to lose, save-state what they would be annoyed to re-navigate.
- Background execution on iOS
- Background time is a set of narrow grants: beginBackgroundTask to finish a critical task, BGAppRefreshTask and BGProcessingTask that the system schedules, BGContinuedProcessingTask (iOS 26) for user-started long work shown in a Live Activity, background URLSession for transfers, and silent pushes that Apple says not to send more than two or three times an hour.
- Background execution on Android
- WorkManager is the default for deferrable and guaranteed work. Foreground services need a declared type and a visible notification; apps targeting Android 15 get at most 6 hours per day for dataSync and mediaProcessing services. Most apps cannot start a foreground service from the background, with exemptions such as a high-priority FCM message.
- React Native AppState
- AppState reports active, background and, on iOS only, inactive; Android adds focus and blur events, and iOS adds memoryWarning. JS gets no event when the app is suspended or killed, and after process death the JS runtime, every store and the navigation stack start empty even if Android restores the native activity.
IN YOUR STACK
TypeScript · React Native AppState Client
import { useEffect, useRef } from "react";
import { AppState } from "react-native";
export function useLifecycle(handlers: { onBackground: () => void; onForeground: () => void }) {
const latest = useRef(handlers);
latest.current = handlers;
const wasBackgrounded = useRef(false);
useEffect(() => {
const sub = AppState.addEventListener("change", (next) => {
if (next === "background") {
wasBackgrounded.current = true;
latest.current.onBackground(); // last reliable moment: no event fires on suspend or kill
} else if (next === "active" && wasBackgrounded.current) {
wasBackgrounded.current = false;
latest.current.onForeground(); // reconnect, refetch, re-check auth
}
// "inactive" (iOS): Control Center, a call or the app switcher. Not a departure.
});
return () => sub.remove();
}, []);
}
// useLifecycle({ onBackground: () => drafts.flushSync(), onForeground: () => queryClient.invalidateQueries() });- Keep onBackground fast and local (write to MMKV, SQLite or AsyncStorage). A network request started here may be frozen mid-flight; queue it and send it on the next foreground.
- Do not treat iOS inactive as background: pausing a video or locking a screen on inactive makes the app flicker every time the user pulls down Control Center.
- Android's blur event fires when the notification shade is pulled down even though AppState stays active.
Swift · SwiftUI scenePhase Client
@main
struct NotesApp: App {
@Environment(\.scenePhase) private var scenePhase
@State private var store = DraftStore() // an @Observable model
var body: some Scene {
WindowGroup { RootView().environment(store) }
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active: store.resync()
case .inactive: break // transient: Control Center, a call, the app switcher
case .background: store.flushToDisk() // suspension follows silently
@unknown default: break
}
}
}
}
struct EditorView: View {
// Per scene, restored by the system after the process is killed. Small values only.
@SceneStorage("editor.noteID") private var noteID: String?
@SceneStorage("editor.tab") private var tab = "body"
var body: some View { NoteEditor(noteID: noteID, tab: $tab) }
}- Read at the App level, scenePhase is an aggregate: .background means every scene is in the background, so it is the right place for app-wide flushing.
- If the flush can take more than a moment (a network upload), wrap it in UIApplication.beginBackgroundTask and end the task when done, or the system terminates the app.
- @SceneStorage is discarded when the user explicitly destroys the scene and is not for sensitive data; persist documents themselves with SwiftData, Core Data or files.
Kotlin · Jetpack Lifecycle + Compose Client
// App-wide foreground/background, the native equivalent of AppState (lifecycle-process).
class AppVisibilityObserver(private val sync: SyncManager) : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) = sync.resume() // first activity started
override fun onStop(owner: LifecycleOwner) = sync.flushAndPause() // last activity stopped
}
class App : Application() {
override fun onCreate() {
super.onCreate()
ProcessLifecycleOwner.get().lifecycle.addObserver(AppVisibilityObserver(SyncManager(this)))
}
}
// Screen-level: subscribe while visible, clean up on ON_STOP or when the composable leaves.
@Composable
fun LivePrices(feed: PriceFeed, viewModel: PricesViewModel) {
LifecycleStartEffect(feed) {
val subscription = feed.subscribe()
onStopOrDispose { subscription.close() }
}
val prices by viewModel.prices.collectAsStateWithLifecycle() // pauses collection in background
PriceList(prices)
}- ProcessLifecycleOwner dispatches ON_STOP with a short delay after the last activity stops, so a rotation does not look like the app went to the background.
- Prefer onStop over onPause for releasing UI resources: in multi-window mode a paused activity can still be fully visible.
- onDestroy and ViewModel.onCleared are not called when the system kills the process; never rely on them to persist anything.
Kotlin · SavedState + rememberSaveable Client
class SearchViewModel(
private val savedState: SavedStateHandle,
private val repo: SearchRepository,
) : ViewModel() {
// ViewModel survives rotation; SavedStateHandle also survives system-initiated process death.
val query: StateFlow<String> = savedState.getStateFlow("query", "")
val results: StateFlow<List<Hit>> = query
.debounce(300)
.mapLatest { repo.search(it) } // results are re-fetched, never saved in the Bundle
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun onQueryChange(q: String) { savedState["query"] = q }
}
@Composable
fun SearchScreen(vm: SearchViewModel) {
val query by vm.query.collectAsStateWithLifecycle()
val hits by vm.results.collectAsStateWithLifecycle()
var filtersOpen by rememberSaveable { mutableStateOf(false) } // pure UI state
val listState = rememberLazyListState() // saveable: scroll position comes back too
SearchLayout(query, vm::onQueryChange, hits, filtersOpen, { filtersOpen = it }, listState)
}- Business-logic state goes in SavedStateHandle; UI-only state goes in rememberSaveable. Both end up in the same Bundle, so store IDs and queries, not lists or images.
- Test it: enable Developer options > Don't keep activities, or background the app and run `adb shell am kill <package>`, then reopen it from Recents.
- In Compose, SavedStateHandle captures values when the activity stops; writes made while it is already stopped are only saved after the next start/stop cycle.
WHEN TO REACH FOR WHAT
beginBackgroundTask (iOS)
Use when Finishing something the user already started when they left: saving a file, sending a message, committing a transaction.
Avoid when Periodic or long work; the time is short, and failing to end the task gets the app terminated.
BGAppRefresh / BGProcessingTask
Use when Opportunistic refresh or maintenance (index rebuilds, ML, cleanup) that the system can run when convenient, for example overnight on power.
Avoid when Anything that must happen at a specific time or promptly; the system may run it late or not at all.
WorkManager (Android)
Use when Deferrable work that must eventually complete, survive process death and reboots, and respect constraints such as network or charging.
Avoid when Work that must start right now and stay visible to the user; use a typed foreground service.
Foreground service (Android)
Use when User-initiated, user-visible ongoing work: navigation, workout tracking, a call, media playback, with the matching service type.
Avoid when Large transfers (prefer user-initiated data transfer jobs) or anything you would start from the background; restrictions and time limits apply.
Silent / data push
Use when The server knows something changed and the app should prefetch it before the user opens it.
Avoid when As a scheduler or a guaranteed channel: it is throttled, dropped for force-quit iOS apps, and deprioritized on Android if it never shows anything.
WHERE IT BITES
- Saving on terminate or onDestroy. Neither runs when the system kills a suspended or cached process; save when the app enters the background.
- Assuming React state survives because the app looked alive in the switcher. After process death a React Native app cold-starts with empty stores and the initial route.
- Keeping a WebSocket or timer as if it ran in the background. Both freeze on suspension; on foreground, reconnect and resync from a cursor rather than trusting the old connection.
- Putting large objects in saved state: it is serialized on the main thread into a Bundle held by the system, and oversized Bundles crash with TransactionTooLargeException.
- Restoring navigation into a screen whose data or auth is gone. Validate the session and refetch by ID before rendering restored screens.
- Testing only rotations and app switches. Process death needs an explicit test: Don't keep activities or adb shell am kill on Android; on iOS, send the app to the background, stop it from Xcode, then relaunch it.
CLOSE THE AI. EXPLAIN THIS.
A user is halfway through a form, switches to their camera app for ten minutes, and comes back. On each platform, which callbacks fired, which never did, what state the OS restores, and what a React Native app must have persisted itself for the form to still be there?WHEN IT BREAKS IN PRODUCTION
SOURCES
- Apple: Managing your app's life cycle (opens in new tab)Checked
- Apple TN3187: Migrating to the UIKit scene-based life cycle (opens in new tab)Checked
- React Native 0.88: SceneDelegate support (opens in new tab)Checked
- Expo Router: Migrating from React Navigation (opens in new tab)Checked
- Apple: Choosing background strategies for your app (opens in new tab)Checked
- Android: The activity lifecycle (opens in new tab)Checked
- Android: Processes and app lifecycle (opens in new tab)Checked
- Android: Foreground service timeouts (opens in new tab)Checked
- React Native: AppState (opens in new tab)Checked
Explainer reviewed