Get a demo
Symptom → evidence

iOS app loses state when backgrounded

Answering: “ios app loses state when backgrounded”

Evidence before populated after the event empty the same screen, captured before and after, comes back blank
Two grounding screenshots from the run timeline, before and after the event: the populated screen comes back empty.

An iOS app loses its session after backgrounding because the trip back is a cold launch: iOS suspends the process, terminates it under memory pressure, and rebuilds the app from scratch. Anything the app never wrote to disk, the session token, the navigation stack, a view model that only lived in memory, is gone.

Come back later, land on the login screen

You sign in, browse a few screens, switch to Messages for a minute, then tap back into the app. Instead of where you left off, you get the launch screen and then the login form. The vault list is empty and the scroll position is gone. A half-filled form comes back blank. When you try to reproduce it, you background the app and immediately reopen it, and everything is fine. The bug only shows up sometimes, usually after the phone has been busy or the app sat in the background for a while.

A suspended app is a terminated app waiting to happen

Backgrounding does not pause your app in place forever. iOS suspends a backgrounded app almost immediately, and while it is suspended the system is free to terminate it to reclaim memory (jetsam). Your app gets no callback when that happens. The next time the user taps your icon, application(_:didFinishLaunchingWithOptions:) runs from scratch. That is a cold launch, and the resume you were counting on never happens.

From there, three things turn a normal cold launch into lost state:

  • No state restoration or scene persistence. @SceneStorage (SwiftUI) and UIScene state restoration (UIKit) let the system persist small scene values like the selected tab across a system-initiated termination. A NavigationPath is not covered automatically; you have to serialize it as Codable yourself, and that is easy to get wrong. Wire up none of this and a cold relaunch rebuilds the initial view hierarchy with the sheet you had open gone. The decrypted vault needs your own store on top of all of that.
  • In-memory-only view models. The session token, the decrypted vault, and the navigation state live in a @StateObject or a singleton with no backing store. Backgrounding alone does not destroy them, which is exactly why an immediate reopen looks fine. Termination followed by relaunch destroys them, and nothing rehydrates them from disk.
  • Teardown on resign-active or background-task expiry. On scenePhase == .background you cancel in-flight work, or you call beginBackgroundTask and its expiration handler clears the session before a write finishes. Now the state is worse than un-restored: it was actively cleared or half-written on the way out.

One detail hides the bug. iOS keeps a UI snapshot for the app switcher, so the thumbnail still shows your populated screen. It feels like the state is right there, up until the cold relaunch throws it away.

What a shared record adds over a local repro

You can get most of the way to this locally. Run the app detached from the debugger on a device, force-quit it while it is suspended, relaunch, and read os_log in Console.app while you watch the screen rebuild. Whether the app comes back where you left off or drops to login is reachable from your own desk.

What is hard that way is turning one sighting into something repeatable and shareable. Reproducing on your machine leaves you correlating a Console window with your memory of what the screen did, and none of that travels to the teammate who has to confirm the fix. And because the trigger is timing-dependent, eyeballing a single run is not a report.

A runtime record collapses the answer into one artifact you can replay and send. The video of the relaunch shows the populated screen coming back empty, dropped onto login. The device logs show application(_:didFinishLaunchingWithOptions:) firing on the return trip, which marks the cold start rather than a warm resume. The timeline lays the run out step by step, each step carrying a grounding screenshot, so you can set the vault screen from before the force-quit next to the login screen after it. Read together they show the state was lost across the cold relaunch, and you infer the cause from your own code, whether the session was never persisted, persisted but never restored, or torn down on the way out. You get the identical read every run because the sequence is scripted, not hand-performed.

Booting the model and OS version from the report is a bonus, not the crux. Restoration semantics barely move release to release; what actually shifts between builds is jetsam, the memory threshold that reaps a suspended app and how quickly it happens. No lab can summon that pressure on cue, so you reproduce the cold launch deliberately and let the matched device keep everything else honest to the report.

Reproduce the cold relaunch on a cloud device

Revyl runs your build on a cloud device, an iOS simulator hosted in the cloud, and records the whole run. Boot the model and OS version the report came in on, install the build, and drive the flow: sign into lockbox, land on the vault list, background the app, force-quit it from the app switcher, then relaunch. A driven force-quit reaches the same cold-launch path as jetsam, so it faithfully tests the thing most apps get wrong, whether lockbox wrote the session to its own store and rehydrates it on launch. One caveat: if the app leans on system @SceneStorage or UIScene restoration instead of its own persistence, a user-style force-quit will not fault it the way memory pressure would, because iOS discards saved scene state after a deliberate quit. Run the sequence as an agent test so it replays identically every time, or drive it by hand from an interactive session and force-quit yourself. Either way the report carries the video, the device logs, and the timeline grounding screenshots, so you can watch the populated vault come back empty on login and read the cold-start launch sequence firing in the logs. React Native and Expo behave the same: in-memory Redux or Zustand state and Expo Router navigation are lost on a cold relaunch unless you persist and rehydrate them, and the same record shows it.

revyl auth login
revyl build --platform ios
revyl device start --platform ios                    # drive the background/force-quit/relaunch by hand
revyl test run vault-survives-backgrounding --build  # replay it as an agent test with a full report

Technical sources

Common questions

Why does my iOS app go back to the login screen after being in the background?
iOS suspended the app and then terminated it to reclaim memory, so the return trip is a cold launch that runs application(_:didFinishLaunchingWithOptions:) from scratch. If the session lived only in memory and nothing persisted or restored it, the rebuilt view hierarchy drops you back on login.
Why can't I reproduce the lost-state bug when I background and reopen the app right away?
An immediate reopen is a resume, so the in-memory view models are still alive and everything looks fine. The bug needs the suspended process to be fully terminated first, which in the field is jetsam under memory pressure and happens after time or pressure, not an instant switch back. Running attached to the Xcode debugger also suppresses that termination path.
How do I test iOS state restoration after the system kills a backgrounded app?
Run the app detached from the debugger, background it, force-quit it from the app switcher, then relaunch and watch what comes back. A recorded run answers it by observation: the video shows the populated screen reloading empty onto login, and the device logs show a cold start when application(_:didFinishLaunchingWithOptions:) fires on the return trip instead of a warm resume. A driven force-quit reaches the same cold-launch path, so it faithfully tests whether your app rehydrates the session from its own on-disk store; it does not exercise the system @SceneStorage or UIScene restoration path, which iOS deliberately skips after a user quit and only runs after a memory-pressure kill.

Reproduce it on a cloud device

Choose a supported model and OS pair, run the flow, and read the full record of what happened.

Start free Read the docs