AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Mobile App State Restoration Debugging Guide

NFNourin Mahfuj Finick··9 min read

Mobile app state restoration debugging is one of the most frustrating categories of bugs you'll encounter — the kind that doesn't surface in your development flow but hits users hard in production. When the system kills your app's process to reclaim memory and the user returns expecting to pick up exactly where they left off, a single missing onSaveInstanceState call or a misconfigured UIStateRestoration identifier can mean lost form data, broken navigation stacks, and angry reviews.

State restoration bugs are insidious because they're invisible during normal testing. Your app works flawlessly when you're stepping through it with the debugger attached. But on a device with 2 GB of RAM running a dozen other apps, your process is a prime candidate for termination. If you haven't handled state preservation correctly, the user comes back to a blank screen — and they blame you, not the OS.

Why Process Death Is Your Real Problem

Both Android and iOS aggressively manage memory by killing background processes. On Android, the LMK (Low Memory Killer) terminates processes based on an oom_adj score; backgrounded apps are the first to go. On iOS, the Jetsam system uses memory pressure notifications and can kill any app that exceeds its memory limit — even foreground ones in extreme cases. What follows is a "cold start" where the system recreates your activity or scene graph, and your job is to restore every piece of transient UI state the user had built up.

The difference between a polished app and a frustrating one often comes down to how gracefully you handle this restoration. Users expect that after switching away to answer a text or check a calendar invite, their scroll position, partially filled form fields, selected tabs, and navigation back stack are all intact. Losing that state is not a crash — it's arguably worse, because it erodes trust silently.

Android: onSaveInstanceState and Its Quirks

The foundational mechanism on Android is onSaveInstanceState(Bundle outState). This method is called before your activity becomes eligible for destruction, giving you a Bundle to stuff with key-value pairs representing your transient UI state. When the system recreates your activity, that same Bundle arrives in onCreate(Bundle savedInstanceState) and onRestoreInstanceState(Bundle savedInstanceState).

override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putString("search_query", searchEditText.text.toString())
    outState.putInt("selected_tab", viewPager.currentItem)
    outState.putBoolean("is_editing", isEditingMode)
}

The first debugging pitfall: onSaveInstanceState is not guaranteed to be called before every destruction. It's called when the system initiates a configuration change or process death, but not when the user explicitly finishes the activity (back press) or when you call finish(). If you're debugging state loss and onSaveInstanceState never fires, check whether your activity is being destroyed intentionally rather than by the system.

The second pitfall: Bundle size limits. While the official documentation is vague, real-world testing shows that Bundles exceeding roughly 500 KB can throw TransactionTooLargeException on some devices. If you're stuffing large objects or bitmaps into the Bundle, you're asking for trouble. Instead, persist large data to a local store (Room, DataStore, or a file) and save only a reference key in the Bundle.

Simulating process death on Android is straightforward: enable "Don't keep activities" in Developer Options, or use an adb command:

adb shell am kill <package-name>

Then relaunch your app from the recent apps screen. If state disappears, you've found a gap.

ViewModel + SavedStateHandle: The Modern Approach

The ViewModel architecture component survives configuration changes but not process death. This tripped up countless developers when Architecture Components first shipped — they assumed ViewModel data persisted through everything. It doesn't. If the process is killed, the ViewModel is destroyed along with it.

Enter SavedStateHandle, available in AndroidX ViewModels. It wraps the same onSaveInstanceState mechanism but exposes it through a clean API inside your ViewModel:

class SearchViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    val searchQuery = savedStateHandle.getLiveData<String>("search_query", "")
    
    fun updateQuery(query: String) {
        savedStateHandle.set("search_query", query)
    }
}

The debugging approach here is different: instead of hunting for missing onSaveInstanceState overrides in your Activity code, you audit your ViewModel constructors. Every transient UI field that isn't persisted to a database should have a corresponding SavedStateHandle key. Tools like BugsPulse's crash reporting platform can help you correlate state restoration failures with specific user sessions by capturing the SavedStateHandle contents at the time of process death — visit https://bugspulse.com to learn how session-level diagnostics can surface these issues before your users report them.

Fragments and Navigation: The Hidden Traps

When using the Navigation component with multiple fragments in a back stack, each fragment gets its own onSaveInstanceState call. But the timing and ordering can be surprising. If you're restoring state in onViewCreated and finding null Bundles, confirm that the fragment is actually being recreated (not reattached). Use savedInstanceState != null as a gate rather than assuming it'll always be present.

A common debugging workflow for fragment state loss involves checking whether FragmentManager has saved its state properly. The FragmentManager maintains its own back stack entries, but if you're doing programmatic fragment transactions outside the Navigation component's managed flow, you might lose state when the manager reconstructs its internal bookkeeping.

iOS: UIStateRestoration and Restoration Identifiers

On iOS, state restoration is opt-in and identifier-driven. Every view controller that participates in the restoration process needs a unique restorationIdentifier set either in Interface Builder or programmatically. The restoration class (or the view controller itself) must implement either UIViewControllerRestoration or adopt the newer NSSecureCoding-based approach.

class DetailViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        restorationIdentifier = "DetailViewController"
    }
    
    override func encodeRestorableState(with coder: NSCoder) {
        super.encodeRestorableState(with: coder)
        coder.encode(selectedItemId, forKey: "selectedItemId")
        coder.encode(scrollOffset, forKey: "scrollOffset")
    }
    
    override func decodeRestorableState(with coder: NSCoder) {
        super.decodeRestorableState(with: coder)
        selectedItemId = coder.decodeObject(forKey: "selectedItemId") as? String
        scrollOffset = coder.decodeDouble(forKey: "scrollOffset")
    }
}

The most common iOS debugging scenario: you've set restorationIdentifier on every view controller, but restoration still fails. The culprit is almost always a missing restorationIdentifier on a parent view controller in the hierarchy. If a parent doesn't have one, the system skips the entire subtree. Trace your view controller hierarchy from the root and verify every level has a restoration identifier — including navigation controllers, tab bar controllers, and any container view controllers.

Simulating iOS process death requires a slightly different approach. You can't kill the app with a terminal command the way you do on Android. Instead, trigger a memory pressure event from Xcode's debug gauge, or run this in the Simulator:

xcrun simctl spawn booted memory_pressure critical

This forces the system to eject background apps, testing your restoration path under realistic conditions.

NSCoding vs. Codable for State Archives

iOS 12+ introduced the Codable protocol as a modern alternative to NSCoding for state encoding. While NSCoding still works, Codable integrates better with Swift's type system and reduces boilerplate. The debugging consideration: Codable archives are stricter about type mismatches. If you change a stored property from Int to String between app versions, NSCoding might silently fail while Codable throws a decoding error that surfaces in your crash reporting tool. For more on proactive error detection in production, check out our guide on silent failure debugging for mobile apps.

Scene-Based State Restoration (iOS 13+)

Apple introduced NSUserActivity-based state restoration alongside the UISceneDelegate lifecycle in iOS 13. Instead of relying solely on view controller identifiers, you create user activities that describe what the user was doing:

func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
    let activity = NSUserActivity(activityType: "com.example.viewingDetail")
    activity.userInfo = ["itemId": currentItemId]
    return activity
}

Debugging scene-based restoration means checking two things: that your NSUserActivity objects are being created with accurate, minimal payloads (they're persisted to disk, so keep them small), and that your scene(_:continue:) delegate method correctly reconstructs the interface from the activity's userInfo.

Common State Restoration Bugs Across Both Platforms

Bug 1: Restoring state for data that no longer exists. You saved a database row ID in the state bundle, but between process death and restoration, a background sync deleted that row. Always validate restored references before using them.

Bug 2: Stale UI state. Saving a Boolean flag like isSubmitting means the user returns to a form that appears to be mid-submission — but the network request was lost when the process died. Reset such flags on restoration or tie them to server-side idempotency keys.

Bug 3: Platform-specific lifecycle gaps. On Android, onRestoreInstanceState fires after onStart but before onResume. If you're initializing state-dependent UI in onCreate, you might overwrite restored values. On iOS, decodeRestorableState can fire before your data sources are ready if you're fetching from the network. Use completion handlers or defer UI updates until restoration is complete via application(_:didDecodeRestorableStateWith:).

Bug 4: Navigation state drift. If your deep-link handling logic differs from your restoration logic, a restored screen might end up at the wrong depth in the navigation stack. Keep your restoration code and your normal navigation code in sync — or better yet, unify them so that restoration simply replays the same navigateTo calls your deep-link handler uses.

Practical Debugging Workflow

Here's a systematic approach to hunting down state restoration bugs, adapted from the monitoring patterns covered in our background task scheduling debugging guide:

  1. Identify the reproduction scenario. Switch to another app, wait 30 seconds on a memory-constrained device, then return. If the problem is intermittent, enable "Don't keep activities" (Android) or use critical memory pressure triggers (iOS) to force the issue.

  2. Collect forensic data. On Android, log onSaveInstanceState and onCreate Bundle sizes and key sets. On iOS, log the encodeRestorableState call count and verify it matches the number of participating view controllers. Use BugsPulse to aggregate these breadcrumb events across sessions.

  3. Audit the view hierarchy. Go through every activity, fragment, and view controller. Every piece of transient UI state — scroll positions, text field contents, toggle states, selected indices — needs a corresponding save and restore call. Use a checklist; don't rely on memory.

  4. Test across OS versions. Android's Bundle behavior changed subtly between API levels 28 and 33, particularly around Parcelable handling. iOS 15 changed how UIStateRestoration interacts with the new UISheetPresentationController. Always test on the minimum and maximum API levels you support.

  5. Automate the check. Write a UI test that backgrounds and foregrounds the app, then asserts that key elements are in the expected state. Run it on CI against multiple device configurations.

Monitoring State Restoration in Production

Even with thorough testing, production throws curveballs. A device with unusually aggressive memory management or a custom ROM with modified process-lifetime policies can break your carefully crafted restoration logic. Instrument your app to detect state restoration failures: if savedInstanceState is non-null but specific keys are missing, log a non-fatal diagnostic. If decodeRestorableState fires but critical identifiers are absent, capture that as a breadcrumb.

BugsPulse's mobile monitoring platform makes this straightforward by aggregating diagnostic breadcrumbs alongside your crash reports, giving you the full story of what happened leading up to a state loss event — without collecting any personally identifiable information.


Ready to catch state restoration bugs before your users do? Start monitoring your Android and iOS apps with BugsPulse's privacy-first crash reporting and session diagnostics platform. Create your free account at app.bugspulse.com/register and ship with confidence.