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. ANavigationPathis not covered automatically; you have to serialize it asCodableyourself, 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
@StateObjector 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 == .backgroundyou cancel in-flight work, or you callbeginBackgroundTaskand 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