← Back to the library
MobileHow it works · Applied · 14 min

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

Lifecycle states on iOS and AndroidRows go from most to least privileged. Transitions into suspended or killed produce no callback, which is why saving has to happen on the way into the background.
iOS (UIScene / SwiftUI)Android (Activity / process)React Native AppStateWhat your code may do
Foreground, interactiveforegroundActive; scenePhase .activeActivity RESUMED; foreground processactiveAnything, but keep the main thread under one frame of work
Visible, not focusedforegroundInactive: Control Center, call, app switcherSTARTED/PAUSED: dialog on top, unfocused multi-windowinactive (iOS) or blur event (Android)Keep rendering; pause input-dependent work such as a camera capture
Background, still runningbackground: about 5 s, more via beginBackgroundTaskSTOPPED; process drops to background, then cachedbackgroundFinish saves and in-flight requests; schedule anything longer
Suspended / cachedSuspended in memory, no CPU, no callback on entryCached process; may get little or no CPU (Android 13+)No event firesNothing: timers, JS and sockets are frozen; servers time you out
Killed by the systemSilent; scene disconnected or process reclaimedLow-memory kill; onDestroy is not guaranteedNo event; next open is a cold startRestore UI state from the OS, data from your own storage
Killed by the userSwiped away: no relaunch for silent pushes until reopenedSwiped from Recents: task and saved state are goneNo eventStart fresh; only persisted data survives
Allowed background workBGAppRefresh/BGProcessing, background URLSession, pushWorkManager, typed foreground services, FCMNone directly; use native modulesAsk the OS; it decides timing based on battery, usage and quotas
From background to killed to restoredThe path behind "I came back and the app had restarted". Steps 1 and 2 are your only chance to save; step 6 is where most apps lose the user's place.
  1. User leaves the appiOS: sceneWillResignActive then sceneDidEnterBackground (scenePhase .background). Android: onPause then onStop. React Native: AppState changes to background.
  2. 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.
  3. 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.
  4. 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.
  5. User returns from switcherThe switcher showed a snapshot, so the user expects continuity. Deep links or notification taps may also be the launch reason.
  6. 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.
  7. 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.
React Native AppState documentation (opens in new tab)

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.
SwiftUI scenePhase documentation (opens in new tab)

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.
Jetpack Lifecycle + Compose documentation (opens in new tab)

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.
SavedState + rememberSaveable documentation (opens in new tab)

TypeScript · React Navigation 7 Client

import AsyncStorage from "@react-native-async-storage/async-storage";
import { NavigationContainer, type NavigationState } from "@react-navigation/native";
import { useEffect, useState } from "react";
import { Linking } from "react-native";
const KEY = "NAVIGATION_STATE_V1"; // bump when the route tree changes
export function Root() {
  const [ready, setReady] = useState(false);
  const [initialState, setInitialState] = useState<NavigationState | undefined>();

  useEffect(() => {
    const restore = async () => {
      if ((await Linking.getInitialURL()) != null) return; // a deep link wins over restore
      const saved = await AsyncStorage.getItem(KEY);
      if (saved) setInitialState(JSON.parse(saved));
    };
    restore().finally(() => setReady(true));
  }, []);

  if (!ready) return null; // keep the splash screen up meanwhile
  return (
    <NavigationContainer initialState={initialState} onStateChange={(s) => AsyncStorage.setItem(KEY, JSON.stringify(s))}>
      <RootStack />
    </NavigationContainer>
  );
}
  • Every route and param must be serializable: no functions or class instances. Pass IDs in params and reload the entity, so a restored screen never shows stale data.
  • Wrap the tree in an error boundary that clears the key; otherwise a screen that crashes on restore crashes on every launch.
  • Expo Router manages the NavigationContainer itself, so these props are not available: track the path with usePathname(), persist it, and redirect to it on launch, as Expo suggests for initial state. Either way, decide which flows (checkout, auth) should not be restored.
React Navigation 7 documentation (opens in new tab)

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?

SOURCES

Explainer reviewed