Mobile OS Resource Management
Implementing App Tombstoning and State Restoration
Learn how to handle system-initiated process termination by saving UI state and persistent data to create a seamless resume experience for users.
In this article
The Illusion of Continuity: Understanding State Persistence
Modern mobile operating systems are masters of deception. They create an environment where users feel like dozens of applications are running simultaneously without ever slowing down the device. In reality, the hardware constraints of a smartphone necessitate a much more aggressive approach to resource management than a traditional desktop environment.
Mobile devices typically do not use a swap file on the internal flash storage to extend available memory because of the high wear and tear on the hardware and the latency involved. Consequently, when the system runs out of RAM, it cannot simply move memory pages to disk. Instead, the kernel must make the difficult decision to terminate processes that are not currently in the foreground.
The primary challenge for developers is ensuring that this system-initiated termination is invisible to the user. If a user is filling out a multi-step insurance claim and switches to their email for a moment, they expect to return to the exact same spot in the claim form. Bridging the gap between a process being killed and the user returning to the app is the core goal of state restoration.
We must distinguish between two types of app restarts. A cold start occurs when the user or the system explicitly closes the app, and it begins from a clean slate. A warm start from a terminated state occurs when the OS kills the process to reclaim memory, and our job is to restore the previous context so perfectly that the user never realizes the app was gone.
The Mechanics of System-Initiated Termination
Android and iOS use different underlying mechanisms to manage memory pressure, but both share the same fundamental philosophy. On Android, the Low Memory Killer (LMK) driver sits within the Linux kernel and monitors the available memory thresholds. When memory drops below a certain level, the LMK starts killing processes based on their oom_adj score, which tracks their visibility and importance to the user.
On iOS, the system uses a daemon called Jetsam to monitor memory usage across all active processes. Jetsam maintains a high-water mark for each application, and if the total system pressure becomes too great, it terminates the processes that are consuming the most memory relative to their current state. This makes memory efficiency a critical part of ensuring your app stays alive longer in the background.
The operating system is not your enemy when it kills your process; it is acting as a steward of the device resources to ensure a responsive experience for the user.
Understanding these mechanics shifts our focus from trying to prevent termination to designing for it. We cannot control when the OS needs more RAM, but we can control how much data we serialize and how efficiently we recover that data. A resilient application treats its own process death as a routine event rather than an exceptional error.
Prioritizing Data for Restoration
Not all data in your application should be treated equally when preparing for restoration. We generally categorize state into three distinct buckets: persistent business data, navigation state, and transient UI state. Each bucket requires a different strategy for storage and retrieval to balance performance and reliability.
Persistent business data, such as items in a shopping cart or a drafted message, should be saved to a local database or a file as soon as it is modified. This data should survive even if the device is restarted or the app is uninstalled and reinstalled. It is the foundation of the user experience and must be treated with the highest durability.
Transient UI state, on the other hand, includes things like the current scroll position, the text currently typed into a search bar, or the expanded state of a list item. This data is only relevant for the current session and should be stored in the lightweight system-provided bundles. It allows the interface to look exactly as it did before the process was reclaimed.
Implementation Strategies for Android and iOS
Modern frameworks have introduced sophisticated APIs to make state management more ergonomic for developers. On Android, the ViewModel component is the standard for holding UI-related data during configuration changes like screen rotations. However, ViewModels alone do not survive process death unless they utilize the SavedStateHandle module.
The SavedStateHandle acts as a key-value map that is automatically persisted by the OS when the process is killed due to memory pressure. When the process is recreated, the system passes this map back to the new ViewModel instance. This allows you to restore small amounts of data without the overhead of a full database migration.
1class SearchViewModel(private val state: SavedStateHandle) : ViewModel() {
2 // Retrieve the previous search query or use an empty string
3 private val queryKey = "search_query"
4 val searchQuery = state.getLiveData<String>(queryKey, "")
5
6 fun updateQuery(newQuery: String) {
7 // This value is now persisted across process death
8 state[queryKey] = newQuery
9 }
10}On iOS, the approach centers around the SceneDelegate and the restoration activity. Apple provides the NSUserActivity class to capture the state of the app at a specific point in time. This activity can represent a specific screen or a complex user flow, allowing the OS to relaunch the app directly into the correct context after it has been purged from memory.
The Persistence Trade-off
While it might be tempting to save every piece of UI state to the system bundle, there are strict size limits that you must respect. On Android, the Bundle has a limit of roughly one megabyte; exceeding this can cause a TransactionTooLargeException and crash your app. On iOS, while the limits are less explicitly documented, saving too much data to the user activity can lead to sluggish transitions and increased disk usage.
The best practice is to save only the unique identifiers needed to reconstruct the state. Instead of saving a whole list of search results, save the search query string and the ID of the last selected item. When the app restores, you can use these IDs to fetch the necessary data from your local cache or the network.
- Use system bundles for small, transient UI states like scroll positions and text inputs.
- Use local databases like Room or Core Data for large datasets and business-critical information.
- Avoid storing large bitmaps or complex objects in state restoration bundles.
- Always validate the restored data to ensure it still represents a consistent state.
Implementing iOS State Restoration
To support restoration on iOS, you must define the activity types your app supports in the info.plist file. During the scene's lifecycle, the system calls specific methods to provide you with the opportunity to save the current state. When the app is relaunched, the scene will be passed back the activity object you previously saved.
1func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
2 // Check if we are being restored from a previous activity
3 if let userActivity = options.userActivities.first ?? session.stateRestorationActivity {
4 if userActivity.activityType == "com.example.productDetail" {
5 let productId = userActivity.userInfo?["productId"] as? String
6 // Navigate the user back to the specific product view
7 navigateToProduct(id: productId)
8 }
9 }
10}
11
12func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
13 // Capture the current state for future restoration
14 let activity = NSUserActivity(activityType: "com.example.productDetail")
15 activity.addUserInfoEntries(from: ["productId": currentVisibleProductId])
16 return activity
17}Validating the Resilience Architecture
One of the biggest pitfalls in mobile development is assuming that state restoration works without testing it explicitly. Because high memory pressure is difficult to trigger naturally during development on high-end testing devices, you must use specialized tools to simulate process death. Without this verification, users will likely encounter bugs that you never saw in your standard debug cycles.
On Android, you can simulate process death by putting your app in the background and then using the ADB command line tool to kill the process. When you select the app from the recent apps switcher, the OS will attempt to restore it from the saved bundle. This is the moment where many apps fail by crashing due to null pointers or displaying an empty screen.
On iOS, you can use the Stop button in Xcode while the app is running in the background, or use the specialized Don't Keep Activities equivalent for iOS by using the memory pressure simulation tools in the developer menu. Verifying that the navigation stack is correctly rebuilt and that the scroll position is maintained is essential for a high-quality product.
Common Pitfalls and Anti-patterns
A common mistake is relying on static variables or singleton instances to hold UI state. These variables are wiped clean when the process is killed, leading to inconsistent behavior when the app restarts. Always assume that any memory-only storage will be lost and that the only reliable data sources are the system-provided bundles and persistent storage.
Another issue is performing heavy database operations directly on the main thread during state restoration. Since the user is waiting for the app to become interactive, these operations should be optimized or handled asynchronously with a placeholder UI. Users are generally forgiving of a brief loading spinner, but they will not tolerate a frozen interface or a crash.
