Skip to content

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:

Process Death Scenario
1. User opens your app, navigates to a detail screen
2. User switches to another app (your app goes to background)
3. System needs memory, kills your app process
4. User returns to your app
5. App restarts from scratch - all ViewModel state is gone

Without 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.

Process death lifecycle
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 screen

Regular 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 MethodSurvives Process DeathSurvives App CloseSize Limit
ViewModel variablesNoNoMemory
SavedStateHandleYesNo~50KB
onSavedInstanceStateYesNo~50KB
SharedPreferencesYesYesUnbounded
Room DatabaseYesYesDisk 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.

Basic SavedStateHandle Injection with Hilt
@HiltViewModel
class 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:

Reactive State with getStateFlow
@HiltViewModel
class 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:

Using SavedStateHandle in Compose
@Composable
fun 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.

SavedStateHandle also receives navigation arguments automatically. This is particularly useful with Navigation Compose:

Navigation Arguments in SavedStateHandle
// Define your route with arguments
composable(
route = "user/{userId}",
arguments = listOf(navArgument("userId") { type = NavType.StringType })
) { backStackEntry ->
UserScreen()
}
// The argument is automatically available in SavedStateHandle
@HiltViewModel
class 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:

Supported Types in SavedStateHandle
@HiltViewModel
class 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

Don't Store Large Objects
@HiltViewModel
class 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

Validate Restored State
@HiltViewModel
class 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

SavedStateHandle is NOT a Database
@HiltViewModel
class 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 Way vs New Way
// OLD: Using onSavedInstanceState in Activity
class 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
@HiltViewModel
class 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:

Testing SavedStateHandle Behavior
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
  • SavedStateHandle survives process death but not user-initiated app close
  • Hilt automatically injects SavedStateHandle into your ViewModels
  • Use getStateFlow for 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