Shared navigation state and the one platform that kills your process
A back stack in memory is correct on desktop and loses the user's place on Android.
TL;DR
- A shared navigation stack held in a
MutableStateFlow. Correct on desktop, correct on iOS, and on Android it dropped the user back to the root screen after any process death — which a backgrounded app on a low-memory device reaches in about 90 seconds. - Three of four platforms never kill a running process out from under you. Android does, routinely, and the shared code had no concept of a state that needs to survive it.
- The fix is a serialisable stack plus one platform hook, and the part that took longest was deciding what not to restore — a restored stack pointing at a deleted note is worse than a reset one.
- What reading on buys: the measurement, which fields earn persistence, and the restore validation that stops a stale stack from crashing on resume.
The case this post is about
A navigation stack in shared code:
// commonMain/kotlin/nav/Navigator.kt
sealed interface Screen {
data object Home : Screen
data class NoteDetail(val noteId: String) : Screen
data class FolderList(val folderId: String) : Screen
data object Settings : Screen
}
class Navigator {
private val _stack = MutableStateFlow<List<Screen>>(listOf(Screen.Home))
val stack: StateFlow<List<Screen>> = _stack.asStateFlow()
fun push(s: Screen) { _stack.update { it + s } }
fun pop() { _stack.update { if (it.size > 1) it.dropLast(1) else it } }
}
// commonMain — the UI
@Composable
fun AppRoot(nav: Navigator) {
val stack by nav.stack.collectAsStateWithLifecycle()
when (val top = stack.last()) {
Screen.Home -> HomeScreen(onOpenNote = { nav.push(Screen.NoteDetail(it)) })
is Screen.NoteDetail -> NoteDetailScreen(top.noteId, onBack = nav::pop)
is Screen.FolderList -> FolderScreen(top.folderId, onBack = nav::pop)
Screen.Settings -> SettingsScreen(onBack = nav::pop)
}
}
The Navigator is a singleton in the DI graph, which is what makes it survive configuration change
on Android — and is also why nothing had flagged it.
The symptom
A support report that read as a bug in the back button:
"I was reading a note, switched to Maps for directions, came back and the app was on
the home screen. Pressing back closed it."
Reproducible with the developer setting that simulates what the OS does anyway:
$ adb shell settings put global background_process_limit 0 # kill backgrounded processes
$ # open a note, background the app, return
observed: Home
expected: NoteDetail(7f2a1c9e)
Measured against the real thing rather than the simulation, on a 4 GB device:
# time from backgrounding to process death, app left in the background, 20 trials
median 94 s
p10 41 s
p90 6 m 12 s
never killed in 30 min 2 of 20
Read that carefully, because I misread it at first. The median is 94 seconds, so about half of two-minute absences end in a dead process — not nine in ten. The nine-in-ten figure belongs to a longer absence: left backgrounded for thirty minutes, 18 of 20 were reclaimed. Either way the user comes back to the root screen. And the other three platforms:
desktop process survives; the stack is in memory and correct
ios the app is suspended, not killed, in the overwhelming majority of cases
web a tab reload restores from the URL, which IS the stack
One platform, routinely, and no shared concept of the thing it does.

Why the obvious fix failed
The first fix was rememberSaveable, which is the Compose mechanism for exactly this:
val stack = rememberSaveable { mutableStateOf(listOf<Screen>(Screen.Home)) }
Two problems. Screen is a sealed hierarchy and not Parcelable, so this throws at runtime on
Android the first time the state is saved — and more structurally, the stack is not composition
state: it is owned by a DI singleton that several screens read, and moving it into a composable's
remember makes it the composition's and loses that.
The second fix was SavedStateHandle, which is the right Android mechanism and does not exist in
commonMain:
class Navigator(private val saved: SavedStateHandle) // androidx, Android-only at the time
We could expect/actual it, and three actuals would have nothing to implement — which is a
signal, and the signal is that persistence is a platform trigger on a shared concern.
The third fix was to persist on every push, to our existing preferences store:
fun push(s: Screen) { _stack.update { it + s }.also { persist() } }
Which works and writes on every navigation — measured at 180 writes in a 10-minute session, each a
disk commit. More importantly it restored too well: a user who had been on
NoteDetail(deleted-note) came back to a detail screen for a note that no longer existed, which
crashed. Restoring state is not the same as restoring a valid state.
The actual mechanism
Process death is one platform's normal operation and three platforms' edge case, and the state it destroys is the state nobody declared as needing to survive.
Android treats a backgrounded process as reclaimable. The system kills it under memory pressure,
which on a mid-range device is routine. On return, the app is recreated from scratch — a new
process, a new DI graph, a new Navigator with its initial value. The Activity gets a
savedInstanceState bundle if something put data in it.
iOS suspends rather than kills, usually. An app in the background is frozen and resumed with its memory intact. It can be terminated under pressure, and the frequency is far lower — our telemetry puts it at about 4% of half-hour backgroundings against Android's 90%.
Desktop and the web do not have the event at all. A desktop process lives until closed. A web tab reload rebuilds from the URL, which is already a serialised navigation state — the web target had been solving this problem for free the whole time.
So the shared concern is "this state must be reconstructible" and the trigger is per platform.
Which means the serialisation belongs in commonMain and the when belongs in one actual — and
the restore needs validation, because the gap between save and restore can be arbitrarily long and
the world changes in it.
That last point is the one the third fix got wrong. A stack is a list of references, and a reference persisted on Tuesday may point at nothing on Wednesday.

The fix
A serialisable stack, saved at the platform's own signal, and validated on restore.
// commonMain/kotlin/nav/Screen.kt
//
// @Serializable, so the stack can be persisted by shared code without any platform type.
// The sealed hierarchy is kept — it is what makes the `when` in AppRoot exhaustive — and
// kotlinx.serialization handles it with a discriminator.
@Serializable
sealed interface Screen {
@Serializable @SerialName("home") data object Home : Screen
@Serializable @SerialName("note") data class NoteDetail(val noteId: String) : Screen
@Serializable @SerialName("folder") data class FolderList(val folderId: String) : Screen
@Serializable @SerialName("settings") data object Settings : Screen
}
// commonMain/kotlin/nav/Navigator.kt
class Navigator(
private val store: NavStateStore,
private val validator: ScreenValidator,
) {
private val _stack = MutableStateFlow<List<Screen>>(listOf(Screen.Home))
val stack: StateFlow<List<Screen>> = _stack.asStateFlow()
fun push(s: Screen) { _stack.update { it + s } }
fun pop() { _stack.update { if (it.size > 1) it.dropLast(1) else it } }
/**
* Called by the PLATFORM at its own save signal, not on every navigation. The original
* persisted per push — 180 disk commits in a ten-minute session — and the data is only
* needed at one moment, which the platform knows and shared code does not.
*/
suspend fun saveState() = store.write(Json.encodeToString(_stack.value))
/**
* Restores, dropping any screen whose target no longer exists. A stack is a list of
* REFERENCES, and the gap between save and restore is unbounded — restoring a detail
* screen for a deleted note crashed, which is worse than the reset it replaced.
*/
suspend fun restoreState() {
val raw = store.read() ?: return
val saved = runCatching { Json.decodeFromString<List<Screen>>(raw) }.getOrNull()
// A decode failure means the stack was written by a version with a different
// shape. Falling back to Home is correct; throwing here would make an app
// upgrade a crash loop, which is the worst available outcome.
?: return
// Validate from the ROOT outward and stop at the first invalid screen, rather than
// filtering. A stack with a hole in it is not a stack the user was ever on — if
// NoteDetail is gone, the FolderList that was pushed on top of it is not reachable
// state either.
val valid = saved.takeWhile { validator.stillExists(it) }
_stack.value = valid.ifEmpty { listOf(Screen.Home) }
}
}
/** Per-screen existence check. The only thing shared code cannot answer by itself. */
interface ScreenValidator { suspend fun stillExists(screen: Screen): Boolean }
// androidMain — the one platform with the trigger
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Restore BEFORE the first composition, so the UI never renders Home and then jumps.
// Doing it in a LaunchedEffect produced a visible flash of the home screen.
//
// This is runBlocking ON THE MAIN THREAD, which is a thing to do deliberately or not
// at all: it is a disk read plus one validation query per saved screen, and a slow
// answer here is an ANR rather than a slow start. It is bounded — the stack is at
// most a handful of entries and the validator reads a local database — and measured
// at 11 ms p99 across the twenty trials. If the validator ever needed the network,
// this line would have to become a splash-screen condition instead.
runBlocking { navigator.restoreState() }
setContent { AppRoot(navigator) }
}
override fun onStop() {
super.onStop()
// onStop, not onPause and not onSaveInstanceState: onPause fires for a transient
// overlay the process will survive, and onSaveInstanceState has a size limit measured
// in hundreds of KB across the whole activity which a stack should not compete for.
lifecycleScope.launch { navigator.saveState() }
}
}
// desktopMain, wasmJsMain — no trigger, deliberately
// Desktop: the process lives until closed, so there is nothing to restore from.
// Web: the URL is the navigation state; the router already handles a reload.
And the output it produced, same device, same twenty trials:
$ ./tools/process-death-probe.sh --trials 20
before after
returned to Home 18 / 20 0 / 20
returned to the right screen 2 / 20 20 / 20
crashed on a deleted target n/a 0 / 20 (3 trials had one; truncated to Home)
disk writes per 10-min session 180 4
How we proved it
That Android kills a backgrounded process routinely — measured rather than assumed, because the support report read as a back-button bug:
time to process death, app backgrounded, 4 GB device, 20 trials
median 94 s
p10 41 s
never killed in 30m 2 / 20
Half of two-minute absences and nine of ten half-hour ones come back to a recreated process.
That the other three platforms do not need it. The number that kept the fix to one actual:
platform backgroundings ending in termination within 30 min
android ~90% (18 / 20; about 50% by the two-minute mark)
ios 4%
desktop 0%
web n/a — a reload restores from the URL
iOS's 4% is not zero, and we chose not to implement the iOS hook: at 4% the restore path would be
exercised rarely enough that it would rot, and the shared saveState is there if we change our
minds.
That persisting per push was expensive and insufficient. Both halves, since the write count alone would not have stopped us:
disk writes / 10 min stack restored usable after restore
per push 180 20 / 20 17 / 20
at onStop 4 20 / 20 17 / 20
at onStop + validation 4 20 / 20 20 / 20
All three rows restore the stack. Only the third produces a stack the user can actually use: in the other two, three of the twenty trials restored a detail screen for a note that had been deleted while the app was away, and rendering it crashed. The write frequency is unrelated to that, which is the point — the defect was in what was restored, not in when it was saved.
That takeWhile is right and filter is wrong. The fixture, because filtering reads as the
obvious way to drop invalid entries:
saved stack: [Home, NoteDetail(deleted), FolderList(f1)]
filter → [Home, FolderList(f1)] ← a stack the user was never on; back goes to Home
from a folder they reached via the note
takeWhile → [Home] ← the last coherent prefix
A hole in a stack produces a back-navigation that never happened.
That restoring in onCreate rather than a LaunchedEffect matters. Measured, because it is a
one-line difference:
restore in a LaunchedEffect Home visible for 60–180 ms, then the correct screen
restore in onCreate the correct screen on the first frame
A flash of the home screen reads as the bug the fix is for.
That a decode failure must not throw. The fixture is an app upgrade that changes the sealed hierarchy, which is the realistic way a saved stack becomes unreadable:
saved by v1.4 (Screen.NoteDetail had no `folderId`), read by v1.5
with runCatching → Home, app starts
without → SerializationException in onCreate → crash on every launch
A crash loop on upgrade, recoverable only by clearing app data. That runCatching is the most
important line in the file, and it looks like defensive noise.
What I cannot tell you is whether onStop catches every path to process death. It is the last
lifecycle callback guaranteed to run before a process can be reclaimed, and a SIGKILL from a
low-memory killer at a bad moment can in principle precede it — the twenty trials all saved
successfully, and twenty is not a proof.
And an honest scope limit. We persist the stack and not the per-screen state — a half-typed
note body, a scroll position, a partially-filled form are all lost. Those belong in
rememberSaveable or a ViewModel's SavedStateHandle per screen, which is a separate and larger
piece of work, and the stack was the one losing people's place.

What this generalizes to
A shared abstraction inherits the union of its platforms' lifecycles, and a state that is safely in memory on three of them is not safely in memory. The useful question for any long-lived shared object is which platform is most willing to destroy it, and to design for that one — the others pay nothing for a state that happens also to be serialisable. And when restoring, remember that stored state is a claim about a world that has since moved: validate it, and prefer the last coherent prefix to a filtered set that no user ever navigated.
Related
- A ViewModel's lifecycle is a property of its owner — the per-screen state this post deliberately does not cover.
- Back means four different things — the other half of shared navigation, where the event rather than the state differs.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".