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
| Event | ViewModel survives | UI state (Bundle) survives |
|---|---|---|
| Screen rotation | Yes | Yes |
| App moved to background (no kill) | Yes | Yes |
| Process death (OS kills app) | No | Yes (via Bundle) |
| App explicitly closed by user | No | No |
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, orSerializable. - 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 SavedStateHandle | Reload from Repository |
|---|---|
| Selected item ID | Full item data |
| Search query string | Search results list |
| Current page number | Loaded pages |
| Filter/sort selection | Filtered list |
| Scroll position | Content at that position |
| Form field values | Dropdown 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
| Concept | Key Point |
|---|---|
| Process death | OS kills background process; memory is gone, ViewModel is gone |
| Bundle (onSaveInstanceState) | Survives process death; 500 KB limit; Activity-scoped |
| SavedStateHandle | Backed by Bundle; wired into ViewModel; survives process death |
| What to save | IDs, queries, selections — not full data objects |
| @Parcelize | Use for small custom objects stored in SavedStateHandle |
| Testing | adb shell am kill + "Don't keep activities" developer option |
| rememberSaveable | Compose equivalent; survives process death for UI-local state |