Surviving Process Death: SavedStateHandle in Android ViewModels
The Problem
I was reviewing an Android developer quiz on Reddit when I noticed something interesting. One question about SavedStateHandle was marked as “senior-level” knowledge. A commenter praised the quiz for including it, saying these topics are “often overlooked.”
That caught my attention. Why would something so fundamental be considered senior-level?
The answer: most developers don’t learn about process death until they encounter it in production. Users complain that their app “lost their place” after switching to another app. The dev investigates, finds nothing in crash logs, and only then discovers that Android killed the app process in the background.
Here’s what process death looks like in practice:
1. User opens your app, navigates to a detail screen2. User switches to another app (your app goes to background)3. System needs memory, kills your app process4. User returns to your app5. App restarts from scratch - all ViewModel state is goneWithout SavedStateHandle, your user loses their place. They see the home screen instead of the detail screen they were viewing. Not a great experience.
What Is Process Death?
Process death is when the Android system kills your app’s process to reclaim memory. This is different from:
- Configuration changes (screen rotation): ViewModel survives these automatically
- User swiping away from recents: That’s an explicit close, not process death
- App crashes: Those show up in crash logs
Process death happens silently. Your app was in the background, the system needed resources, so it killed the process. When the user returns, Android recreates the activity stack, but your in-memory state is gone.
sequenceDiagram participant User participant App as Your App participant System as Android System participant Storage as SavedStateHandle
User->>App: Opens app, navigates around App->>App: Stores state in ViewModel User->>System: Switches to another app Note over App: App goes to background System->>App: Kills process (needs memory) Note over App: All in-memory state destroyed User->>System: Returns to your app System->>App: Recreates process App->>Storage: Restores from SavedStateHandle App->>User: User sees their previous screenRegular ViewModel state doesn’t survive this. That’s where SavedStateHandle comes in.
SavedStateHandle vs Other State Options
Before diving into implementation, let me clarify how SavedStateHandle differs from other persistence options:
| Storage Method | Survives Process Death | Survives App Close | Size Limit |
|---|---|---|---|
| ViewModel variables | No | No | Memory |
| SavedStateHandle | Yes | No | ~50KB |
| onSavedInstanceState | Yes | No | ~50KB |
| SharedPreferences | Yes | Yes | Unbounded |
| Room Database | Yes | Yes | Disk space |
SavedStateHandle sits in a sweet spot: it survives process death but doesn’t require disk I/O during normal operation. It’s designed for transient UI state that needs to persist across process recreation.
How SavedStateHandle Works with Hilt
The beauty of Hilt is that it handles SavedStateHandle injection automatically. You don’t need to manually create or restore state.
@HiltViewModelclass UserViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private val userRepository: UserRepository) : ViewModel() {
// Reading navigation arguments private val userId: String = savedStateHandle["userId"] ?: ""
// Storing UI state that survives process death var userName: String get() = savedStateHandle["userName"] ?: "" set(value) { savedStateHandle["userName"] = value }}Hilt automatically provides the SavedStateHandle when creating your ViewModel. This handle is pre-populated with any navigation arguments and any state saved before the last process death.
Using getStateFlow for Reactive State
For Compose UIs, you often want reactive state. SavedStateHandle provides getStateFlow for this purpose:
@HiltViewModelclass EditorViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle) : ViewModel() {
// This StateFlow survives process death automatically val draftContent: StateFlow<String> = savedStateHandle .getStateFlow("draftContent", "")
val lastEditTime: StateFlow<Long> = savedStateHandle .getStateFlow("lastEditTime", 0L)
fun updateDraft(content: String) { savedStateHandle["draftContent"] = content savedStateHandle["lastEditTime"] = System.currentTimeMillis() }}In your Compose UI:
@Composablefun EditorScreen(viewModel: EditorViewModel = hiltViewModel()) { val draftContent by viewModel.draftContent.collectAsState()
TextField( value = draftContent, onValueChange = { viewModel.updateDraft(it) }, modifier = Modifier.fillMaxSize() )}When the user returns after process death, their draft content is restored automatically.
Navigation Arguments and SavedStateHandle
SavedStateHandle also receives navigation arguments automatically. This is particularly useful with Navigation Compose:
// Define your route with argumentscomposable( route = "user/{userId}", arguments = listOf(navArgument("userId") { type = NavType.StringType })) { backStackEntry -> UserScreen()}
// The argument is automatically available in SavedStateHandle@HiltViewModelclass UserViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private val userRepository: UserRepository) : ViewModel() {
// Navigation argument is already in SavedStateHandle private val userId: String = savedStateHandle["userId"] ?: throw IllegalArgumentException("userId required")
val user: StateFlow<User?> = flow { emit(userRepository.getUser(userId)) } .stateIn(viewModelScope, SharingStarted.Lazily, null)}No need to manually extract arguments from the navigation back stack entry. Hilt and Navigation handle this for you.
What Can You Store in SavedStateHandle?
Not everything belongs in SavedStateHandle. It has the same limitations as a Bundle:
@HiltViewModelclass FormViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle) : ViewModel() {
// Supported primitive types fun savePrimitives() { savedStateHandle["name"] = "John Doe" // String savedStateHandle["age"] = 30 // Int savedStateHandle["score"] = 95.5 // Double savedStateHandle["isActive"] = true // Boolean savedStateHandle["timestamp"] = System.currentTimeMillis() // Long }
// Supported collection types fun saveCollections() { savedStateHandle["tags"] = listOf("kotlin", "android") savedStateHandle["scores"] = intArrayOf(90, 85, 95) }
// Parcelable and Serializable (with caveats) fun saveParcelable(user: User) { savedStateHandle["user"] = user // User must implement Parcelable }
// NOT recommended - too large fun badExample() { // DON'T: Large lists or bitmaps savedStateHandle["largeList"] = (1..10000).toList() // Too big! savedStateHandle["profileImage"] = bitmap // Won't work! }}The practical size limit is around 50KB. Store identifiers and small pieces of state, not entire data structures.
Common Pitfalls
I’ve seen several mistakes when teams adopt SavedStateHandle:
Pitfall 1: Storing Too Much Data
@HiltViewModelclass BadViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle) : ViewModel() {
// WRONG: Storing entire API response fun saveResponse(response: LargeApiResponse) { savedStateHandle["response"] = response // Could exceed 50KB }
// RIGHT: Store only what you need fun saveEssentialData(response: LargeApiResponse) { savedStateHandle["selectedId"] = response.selectedItem.id savedStateHandle["scrollPosition"] = response.scrollPosition }}Pitfall 2: Not Validating Restored State
@HiltViewModelclass FormViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle) : ViewModel() {
private val currentStep: StateFlow<Int> = savedStateHandle .getStateFlow("currentStep", 0)
init { // Validate that restored step is still valid val restoredStep = currentStep.value if (restoredStep < 0 || restoredStep > MAX_STEPS) { savedStateHandle["currentStep"] = 0 // Reset to valid state } }}Pitfall 3: Confusing with Persistent Storage
@HiltViewModelclass WrongUsageViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle) : ViewModel() {
// WRONG: Using SavedStateHandle for persistent data fun saveUserPreferences(theme: String) { savedStateHandle["theme"] = theme // Lost when user closes app! }
// RIGHT: Use DataStore or SharedPreferences for preferences // Use SavedStateHandle for transient UI state only}SavedStateHandle vs onSavedInstanceState
You might wonder: isn’t this what onSavedInstanceState is for? Yes, but SavedStateHandle is the modern approach:
// OLD: Using onSavedInstanceState in Activityclass OldActivity : AppCompatActivity() { private var userName: String = ""
override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString("userName", userName) }
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) userName = savedInstanceState?.getString("userName") ?: "" }}
// NEW: Using SavedStateHandle in ViewModel@HiltViewModelclass NewViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle) : ViewModel() {
val userName: StateFlow<String> = savedStateHandle .getStateFlow("userName", "")
fun setUserName(name: String) { savedStateHandle["userName"] = name }}The ViewModel approach is cleaner and testable. Your state logic lives in the ViewModel, not scattered across activity lifecycle callbacks.
Testing SavedStateHandle
Testing ViewModels with SavedStateHandle is straightforward:
class UserViewModelTest { @Test fun `restores userName after process death simulation`() { // Create SavedStateHandle with initial state val savedStateHandle = SavedStateHandle(mapOf("userId" to "user123"))
val viewModel = UserViewModel( savedStateHandle = savedStateHandle, userRepository = FakeUserRepository() )
// Simulate user input viewModel.updateUserName("John Doe")
// Verify state is stored assertEquals("John Doe", savedStateHandle["userName"])
// Simulate process death by creating new ViewModel with same SavedStateHandle val restoredViewModel = UserViewModel( savedStateHandle = savedStateHandle, userRepository = FakeUserRepository() )
// State is preserved assertEquals("John Doe", restoredViewModel.userName.value) }}When to Use SavedStateHandle
Use SavedStateHandle for:
- User input in progress (draft messages, form data)
- Scroll positions and UI state
- Navigation arguments that need to survive process death
- Filter/sort preferences for a screen
- Selected items in a list
Don’t use it for:
- User preferences (use DataStore)
- Cached data (use Room or a proper cache)
- Large data sets
- Anything that needs to persist across app restarts
Summary
SavedStateHandle is essential for production Android apps. It fills the gap between in-memory ViewModel state and persistent storage, ensuring users don’t lose their place when the system kills your app in the background.
Key points I covered:
- Process death kills your app silently; regular ViewModel state is lost
SavedStateHandlesurvives process death but not user-initiated app close- Hilt automatically injects
SavedStateHandleinto your ViewModels - Use
getStateFlowfor reactive state in Compose - Navigation arguments are automatically populated in
SavedStateHandle - Keep stored data small (under 50KB)
- Validate restored state in case of corrupt data
The Reddit quiz author was right to call this senior-level knowledge. It’s one of those things you learn after a production incident. But now you know about it before that happens.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments