Config Changes & Process Death Explained

ANDROID › Lifecycle

Ask an Android engineer to fix a screen that loses its state, and you'll often see the same reflex: slap android:configChanges on the Activity and move on. That papers over rotation, and only rotation. It does nothing when the OS kills your process in the background to reclaim memory, and it does nothing when the user swipes your task away from Recents. Interviewers use this topic to separate engineers who understand *why* a screen's state disappeared from engineers who only know one trick. The system tears down your UI for at least three different reasons, and each one leaves a different amount of state behind. Get the reason right, and picking the right tool, ViewModel, SavedStateHandle, or plain disk storage, becomes obvious.

Configuration changes aren't limited to rotation. Anything that alters the system's Configuration object counts: rotating the device, changing the system language or locale, toggling dark or light (night) mode, resizing the app's window (entering multi-window, unfolding a foldable), changing the system font scale, and plugging in or removing a hardware keyboard. All of them funnel through the exact same mechanism as rotation, by default, destroy the Activity, rebuild it with the updated Configuration. That's worth knowing precisely because interviewers like to ask about a trigger that isn't rotation, to check whether you learned the rule or just memorized the one example everyone starts with.

Two of these "screen went blank" events get conflated constantly, and the whole topic hinges on telling them apart. A configuration change destroys only the Activity instance. The app's *process*, and everything sitting in its memory, keeps running underneath, so anything scoped to that process comes back untouched. System-initiated process death is a different animal: the OS kills the *entire app process* to reclaim memory, almost always while the app is sitting in the background. Every object in memory goes with it. All that comes back afterward is whatever was explicitly serialized somewhere durable, either into a saved-state Bundle, or onto disk.

ViewModel is the standard fix for configuration changes. It lives in a ViewModelStore that the framework deliberately retains across Activity recreation, so a rotated screen gets back the exact same ViewModel instance, with whatever state it was already holding. That retention, though, is still just an object sitting in process memory. It has nowhere else to go, so when the process itself is killed, the ViewModel goes with it, and the next launch constructs a brand new one from scratch.

// BAD: plain field, zeroed on every recreation
class CounterActivity : AppCompatActivity() {
    private var counter = 0
}

// BETTER: survives rotation, NOT process death
class CounterViewModel : ViewModel() {
    var counter = 5
}
class CounterActivity : AppCompatActivity() {
    private val vm: CounterViewModel by viewModels()
}

So what *does* survive process death? Anything the framework serializes into the saved-state Bundle: onSaveInstanceState, a ViewModel's SavedStateHandle, and Compose's rememberSaveable. All three are backed by the same mechanism, the activity's saved-state registry, restored automatically when the process is rebuilt. Whatever you hand it has to be a type the Bundle itself understands: strings, numbers, booleans, null, or lists made purely of those, nothing fancier without extra work. Anything you didn't explicitly save just falls back to whatever default your code constructs it with. And disk, Room, DataStore, plain files, survives absolutely everything, since it lives outside the process entirely.

class MyViewModel(private val state: SavedStateHandle) : ViewModel() {
    var query: String
        get() = state["query"] ?: ""
        set(v) { state["query"] = v }   // backed by Bundle, survives process death

    val results = mutableListOf<Result>() // plain field, lost on process death
}

There's a third way a screen can go away, and it's the one most people forget: the user deliberately ending it, pressing Back until the Activity finishes, calling finish() directly, or swiping the task off the Recents screen. The framework treats that as a clean exit, not something it might need to reconstruct, so it doesn't even bother calling onSaveInstanceState in that case. Nothing gets written to the saved-state Bundle, which means both the ViewModel and anything in SavedStateHandle or rememberSaveable are gone on the next launch. The user expects that. Swiping an app away and having it come back exactly where you left off, mid-form, would feel wrong. Only data that made it all the way to disk, Room, DataStore, plain files, survives this kind of exit, the same as it survives everything else.

That Bundle isn't just theoretically limited, it's practically dangerous to fill carelessly. Saved state gets serialized on the main thread and delivered to the system process across a Binder transaction, and Binder transactions share a modest buffer, roughly one megabyte, across the whole app. Blow through it and you get a TransactionTooLargeException, sometimes on a device that worked fine yesterday, since other in-flight transactions share that same budget. Bigger types cost more of that budget than smaller ones, a long list of strings adds up fast, a handful of integers barely registers, which is exactly why the fix is never "store less of the big thing," it's "store an ID instead of the thing."

// BAD: may throw TransactionTooLargeException
outState.putParcelableArrayList("items", hugeList)

// GOOD: store only a lightweight key
outState.putString("query", currentQuery)
outState.putInt("scrollIndex", currentScrollIndex)

Put those two facts together, saved state has to be tiny, and disk survives everything, and a pattern falls out. The full, canonical version of a list, a document, a user's data, belongs in Room or DataStore. That's the source of truth, and it survives a config change, a process kill, and a user swiping the app away. A ViewModel then holds a cheap in-memory cache of that data for fast access while the screen is alive, rebuilt from disk if the process gets killed. Saved state carries only the thin thread that lets you find your place again, a query string, a scroll position, a selected ID, small enough to never come near the Binder limit.

@Dao interface ItemDao {
    @Query("SELECT * FROM items") fun getAll(): Flow<List<Item>>
}

class ItemsViewModel(
    repo: ItemRepository,
    private val state: SavedStateHandle
) : ViewModel() {
    val items = repo.getAll().stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
    var scrollIndex: Int
        get() = state["scroll"] ?: 0
        set(v) { state["scroll"] = v }
}

You can tell the system **not** to recreate the Activity for specific changes with android:configChanges in the manifest. You get onConfigurationChanged() instead, and Compose simply recomposes with the new values. This is sometimes useful, but it is **not a fix for process death**, it does nothing at all for that case, the manifest attribute only governs recreation behavior. Suppressing recreation this way is also a trade-off: you lose the automatic re-layout and resource re-selection that recreation gives you for free, and you now own updating the UI manually inside onConfigurationChanged. You still need ViewModel and saved state regardless.

<activity
    android:name=".MyActivity"
    android:configChanges="orientation|screenSize" />
override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)
    // update the UI manually here
}
// But if the OS kills the process for memory, this Activity still needs ___

Jetpack Compose has its own version of this split. Plain remember holds a value only across recomposition, it's gone the moment the Activity is recreated, whether that's a config change or anything more drastic. rememberSaveable is the Compose equivalent of SavedStateHandle, it writes into that same saved-state Bundle, so it survives recreation and process death, just not a user swiping the task away. One catch: the Bundle only understands certain types, so a custom data class dropped straight into rememberSaveable crashes at runtime. Make it Parcelable, and it will serialize automatically, or hand rememberSaveable a custom Saver that knows how to convert your type to and from something the Bundle can hold.

@Parcelize
data class FilterState(val query: String, val page: Int) : Parcelable

@Composable
fun Screen() {
    var filter by rememberSaveable { mutableStateOf(FilterState("", 0)) }
}

// Or, when Parcelable doesn't fit:
val FilterSaver = Saver<FilterState, Bundle>(
    save    = { bundleOf("q" to it.query, "p" to it.page) },
    restore = { FilterState(it.getString("q", ""), it.getInt("p")) }
)

Two more pieces round out the picture. First, why process death almost always strikes a backgrounded app and almost never a foreground one: the OS kills processes to reclaim memory strictly by priority, and a visible, foreground app sits near the very top of that list, it's the last thing reclaimed, not the first. A backgrounded app has nothing protecting it, which is why you should always test process death by backgrounding first. Second, inside onStop() or onDestroy(), you can ask the Activity itself why it's tearing down: isChangingConfigurations() returns true when a configuration change caused it, letting you skip expensive cleanup you know is about to be undone a moment later, and do that cleanup for real otherwise.

override fun onStop() {
    super.onStop()
    if (isChangingConfigurations) {
        // About to be recreated, skip expensive teardown
    } else {
        // Real stop: user navigated away, or process is at risk
        saveWorkToPersistentStorage()
    }
}

You can't just eyeball process death, it needs to be reproduced deliberately. Background the app, then simulate the system reclaiming memory: send it Home, then kill its process with adb, then relaunch from Recents and check that saved state comes back while the ViewModel doesn't. Developer Options has a shortcut for the same idea, "Don't keep activities," which finishes every backgrounded Activity, clearing its ViewModel and forcing a saved-state restore, the moment you leave it. It's convenient, but imperfect: the process itself stays alive the whole time, so anything cached outside the Activity or ViewModel, a singleton, a repository's in-memory cache, survives that test even though a real kill would have taken it too. Treat it as a screen-level check, not a full substitute for an actual adb kill.

# 1. Send the app to the background (press Home)
# 2. Kill its process, simulating an OS memory reclaim
adb shell am kill com.example.myapp
# 3. Relaunch from Recents
#    -> saved-state Bundle is restored, ViewModel is NOT

Every fact in this topic reduces to one mental model: ask why the screen is going away, and the answer tells you exactly what's left standing. A configuration change, rotate, locale, dark mode, window size, font scale, keyboard, tears down only the Activity, so anything process-scoped, your ViewModel, comes back intact. System-initiated process death takes the whole process with it, so only what you explicitly wrote into the saved-state Bundle, SavedStateHandle, rememberSaveable, onSaveInstanceState, survives, and it has to stay small enough to cross that Binder transaction. A user-initiated exit, Back, finish(), swiping off Recents, wipes both of those on purpose, because the user asked for a clean slate. Disk is the only thing immune to all three. In an interview, that's the answer: name the event, name what's scoped to the process, what's scoped to the Bundle, and what's scoped to disk, and explain why android:configChanges only ever touches the first one.

Back to Config Changes & Process Death