androidengineers.Book a session

State Management

Handling Process Death with SavedState

article20 minHard

What Is Process Death?

When your app goes to the background, Android may kill its process to reclaim memory for other apps or system tasks. When the user returns to your app, Android recreates it from scratch — a new process, a new Application instance, new ViewModels, empty memory.

This is different from configuration change (rotation), where the process stays alive and ViewModels survive. With process death, everything in memory is gone, including your ViewModel's state.

The three survival scenarios

EventViewModel survivesUI state (Bundle) survives
Screen rotationYesYes
App moved to background (no kill)YesYes
Process death (OS kills app)NoYes (via Bundle)
App explicitly closed by userNoNo

The implication: ViewModel alone is not enough to handle process death. You need SavedStateHandle.

onSaveInstanceState (The Traditional Approach)

Before SavedStateHandle, the mechanism was Activity.onSaveInstanceState(outState: Bundle). Android calls this before the process might be killed; the Bundle is passed back to onCreate when the Activity is recreated.

class SearchActivity : AppCompatActivity() {
    private var searchQuery: String = ""

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Restore after process death
        searchQuery = savedInstanceState?.getString("search_query") ?: ""
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putString("search_query", searchQuery)
    }
}

Bundle limitations

  • Size limit: approximately 500 KB (transaction buffer). Exceed it and you get a TransactionTooLargeException — often a hard-to-reproduce crash.
  • No complex objects without serialization: you must serialize to primitives, Parcelable, or Serializable.
  • Activity-only: Fragment/ViewModel cannot directly participate without boilerplate.

SavedStateHandle is the modern solution that addresses these drawbacks.

SavedStateHandle in ViewModel

SavedStateHandle is a key-value map backed by the same saved instance state mechanism, but wired into the ViewModel. It survives both rotation and process death.

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val searchRepository: SearchRepository
) : ViewModel() {

    companion object {
        private const val KEY_QUERY = "search_query"
        private const val KEY_SELECTED_FILTER = "selected_filter"
    }

    // Read/write through SavedStateHandle — automatically persisted
    var searchQuery: String
        get() = savedStateHandle[KEY_QUERY] ?: ""
        set(value) { savedStateHandle[KEY_QUERY] = value }

    var selectedFilter: String
        get() = savedStateHandle[KEY_SELECTED_FILTER] ?: "ALL"
        set(value) { savedStateHandle[KEY_SELECTED_FILTER] = value }

    // StateFlow backed by SavedStateHandle — updates survive process death
    val queryFlow: StateFlow<String> = savedStateHandle.getStateFlow(KEY_QUERY, "")

    private val _searchResults = MutableStateFlow<List<SearchResult>>(emptyList())
    val searchResults: StateFlow<List<SearchResult>> = _searchResults.asStateFlow()

    init {
        // If the app was killed and restored, the previous query is still here
        viewModelScope.launch {
            queryFlow
                .debounce(300)
                .distinctUntilChanged()
                .filter { it.length >= 2 }
                .collect { query ->
                    performSearch(query)
                }
        }
    }

    fun onQueryChanged(query: String) {
        savedStateHandle[KEY_QUERY] = query
        // queryFlow automatically emits the new value
    }

    private suspend fun performSearch(query: String) {
        try {
            val results = searchRepository.search(query)
            _searchResults.value = results
        } catch (e: Exception) {
            // handle error
        }
    }
}

How SavedStateHandle is provided

With Hilt, inject it directly. Without Hilt:

// No Hilt — manual ViewModel with SavedStateHandle
class SearchViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() { /* ... */ }

// In Fragment/Activity
val viewModel: SearchViewModel by viewModels {
    SavedStateViewModelFactory(application, this)
}

// Or with a custom factory
val viewModel: SearchViewModel by viewModels {
    object : AbstractSavedStateViewModelFactory(this, intent.extras) {
        override fun <T : ViewModel> create(
            key: String, modelClass: Class<T>, handle: SavedStateHandle
        ): T = SearchViewModel(handle) as T
    }
}

What to Save vs What to Reload

SavedStateHandle uses Bundles internally, so the 500 KB limit still applies. The rule: save identifiers, not full objects.

// BAD — serializing a large list to Bundle
savedStateHandle["articles"] = articles // might exceed 500 KB

// GOOD — save the ID (or query), reload the data from repository
savedStateHandle["category_id"] = selectedCategoryId

// On restore: repository fetches fresh data using the saved ID
init {
    val categoryId = savedStateHandle.get<String>("category_id") ?: return
    viewModelScope.launch {
        _articles.value = repository.getArticlesForCategory(categoryId)
    }
}

Decision guide

Save in SavedStateHandleReload from Repository
Selected item IDFull item data
Search query stringSearch results list
Current page numberLoaded pages
Filter/sort selectionFiltered list
Scroll positionContent at that position
Form field valuesDropdown options

Parcelable and @Parcelize

For saving custom objects (keep them small), implement Parcelable:

// Manual Parcelable — verbose
class UserFilter(val category: String, val minRating: Float) : Parcelable {
    constructor(parcel: Parcel) : this(
        parcel.readString() ?: "",
        parcel.readFloat()
    )
    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeString(category)
        parcel.writeFloat(minRating)
    }
    override fun describeContents() = 0
    companion object CREATOR : Parcelable.Creator<UserFilter> {
        override fun createFromParcel(parcel: Parcel) = UserFilter(parcel)
        override fun newArray(size: Int) = arrayOfNulls<UserFilter>(size)
    }
}

// @Parcelize — use this instead (kotlin-parcelize plugin)
@Parcelize
data class UserFilter(
    val category: String,
    val minRating: Float
) : Parcelable

// Now save it in SavedStateHandle
savedStateHandle["user_filter"] = UserFilter("Electronics", 4.0f)
val filter = savedStateHandle.get<UserFilter>("user_filter")

Enable the plugin in your module's build.gradle:

plugins {
    id("kotlin-parcelize")
}

Testing Process Death

Method 1: adb shell am kill

# 1. Put app in background (press Home)
# 2. Kill the process
adb shell am kill com.yourapp.package

# 3. Return to app via Recents — the system recreates it
# State saved via SavedStateHandle/onSaveInstanceState should be restored
# ViewModels will be fresh — verify they re-initialize correctly

Method 2: Don't keep activities (developer option)

Settings → Developer Options → Don't keep activities → ON

Every time you press Home the activity is destroyed, exercising the save/restore cycle continuously during development.

Method 3: Unit test SavedStateHandle directly

@Test
fun `search query is restored after process death`() {
    val savedStateHandle = SavedStateHandle(mapOf("search_query" to "kotlin"))
    val viewModel = SearchViewModel(savedStateHandle, fakeRepository)

    // ViewModel reads the persisted query from SavedStateHandle
    assertThat(viewModel.searchQuery).isEqualTo("kotlin")
}

@Test
fun `saving query updates SavedStateHandle`() {
    val savedStateHandle = SavedStateHandle()
    val viewModel = SearchViewModel(savedStateHandle, fakeRepository)

    viewModel.onQueryChanged("android")

    assertThat(savedStateHandle.get<String>("search_query")).isEqualTo("android")
}

rememberSaveable in Compose

In Compose, rememberSaveable is the equivalent of onSaveInstanceState for composable-local state. It survives both rotation and process death.

@Composable
fun FilterChips() {
    // Survives rotation AND process death
    var selectedFilter by rememberSaveable { mutableStateOf("ALL") }

    Row {
        listOf("ALL", "POPULAR", "NEW").forEach { filter ->
            FilterChip(
                selected = selectedFilter == filter,
                onClick = { selectedFilter = filter },
                label = { Text(filter) }
            )
        }
    }
}

// rememberSaveable with a custom Saver for non-Parcelable types
val colorSaver = Saver<Color, Int>(
    save = { it.toArgb() },
    restore = { Color(it) }
)

@Composable
fun ColorPicker() {
    var selectedColor by rememberSaveable(stateSaver = colorSaver) {
        mutableStateOf(Color.Red)
    }
    // ...
}

Use rememberSaveable for ephemeral UI state (expanded/collapsed, selected tab) that should survive process death but is too trivial to put in a ViewModel.

Common Gotchas

Gotcha 1: Confusing rotation survival with process death survival

ViewModel survives rotation. It does NOT survive process death. If you store data only in ViewModel fields without SavedStateHandle, users lose state when the OS kills your app.

Gotcha 2: Saving too much

Putting a large list into SavedStateHandle causes TransactionTooLargeException. Save IDs and query parameters; reload the data from the repository.

Gotcha 3: Not testing process death

Most developers test rotation but never test process death. Use adb shell am kill regularly during development.

Gotcha 4: Serializable vs Parcelable

Serializable works but is slow (reflection-based). Always use @Parcelize for objects stored in SavedStateHandle.

Gotcha 5: Forgetting to initialize state from SavedStateHandle

// BUG: ignores restored state, always starts fresh
init {
    _searchQuery.value = "" // overwrites what SavedStateHandle stored!
}

// CORRECT: read from SavedStateHandle as the initial value
private val _searchQuery = MutableStateFlow(
    savedStateHandle.get<String>(KEY_QUERY) ?: ""
)

Key Takeaways

ConceptKey Point
Process deathOS kills background process; memory is gone, ViewModel is gone
Bundle (onSaveInstanceState)Survives process death; 500 KB limit; Activity-scoped
SavedStateHandleBacked by Bundle; wired into ViewModel; survives process death
What to saveIDs, queries, selections — not full data objects
@ParcelizeUse for small custom objects stored in SavedStateHandle
Testingadb shell am kill + "Don't keep activities" developer option
rememberSaveableCompose equivalent; survives process death for UI-local state

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Handling Process Death with SavedState | Android System Design | Android Engineers