Back means four different things, and shared navigation has to pick
A hardware button, an edge swipe, a window close and a browser history entry are not the same event. One shared handler gets at least two of them wrong.
TL;DR
- One
BackHandlerin shared Compose code. It worked on Android, was never invoked by the iOS edge-swipe, closed the whole window on desktop, and on web let the browser navigate away while the app still thought it was on the screen — three of four wrong. - "Back" is not one event. It is a hardware button, a gesture the OS drives interactively, a window-close request, and a history entry owned by the browser. Only the first is a callback you can intercept with no further obligations.
- The fix separates intent (the user wants to leave this screen) from mechanism (what each platform sends), and accepts that the iOS gesture cannot be intercepted the way the others can.
- What reading on buys: the per-platform table with what each event can and cannot do, the unsaved-changes dialog that only worked on Android, and the measured loss rate.
The case this post is about
A shared editor screen with unsaved-changes protection:
// feature/editor/src/commonMain/kotlin/EditorScreen.kt
@Composable
fun EditorScreen(vm: EditorViewModel, onNavigateUp: () -> Unit) {
var showDiscardDialog by remember { mutableStateOf(false) }
// The whole protection, in one place.
BackHandler(enabled = vm.hasUnsavedChanges) { showDiscardDialog = true }
if (showDiscardDialog) {
DiscardDialog(
onDiscard = { showDiscardDialog = false; onNavigateUp() },
onKeepEditing = { showDiscardDialog = false },
)
}
EditorBody(vm)
}
BackHandler in Compose Multiplatform is an expect with per-platform actuals. That is the part
the code reads as having handled.
The symptom
Lost drafts, reported from everywhere except Android:
# sessions ending with unsaved changes and no discard dialog shown, 2 weeks
platform sessions with unsaved changes dialog shown drafts lost
android 1,840 1,840 0
ios 1,210 0 1,210
desktop 340 12 328
web 890 41 849
Three platforms, 2,387 lost drafts, zero dialogs on iOS. And each failed differently, which is what made it look like three separate bugs:
ios the edge-swipe completed; the screen popped; no callback ever fired
desktop clicking the window's close button quit the app entirely
web the browser back button navigated away; our router still showed the editor
All 12 desktop dialogs came from a `Cmd+[` shortcut one build had wired by hand — the only
mechanism on that platform that reached the guard at all.

Why the obvious fix failed
The first fix was to add onDisappear-style cleanup on iOS:
DisposableEffect(Unit) { onDispose { if (vm.hasUnsavedChanges) vm.autosave() } }
Autosave rather than a dialog — which is a different product decision smuggled in as a bug fix, and it does not work either: the dispose runs after the pop animation starts, so the user has already seen the previous screen and has no idea anything was saved. We kept it as a safety net and it is not the fix.
The second fix was to disable the iOS edge-swipe:
navigationController.interactivePopGestureRecognizer?.isEnabled = false
This makes BackHandler reachable, by removing the gesture iOS users expect on every screen. The
App Store review did not object; three internal testers did, immediately, and they were right.
The third fix on web was beforeunload:
window.addEventListener("beforeunload") { e -> e.preventDefault() }
beforeunload fires for leaving the page, not for a history navigation within a single-page app.
Our router is in-page, so the browser back button never triggers it. It does fire when the user
closes the tab, which is a real case and a different one.
The actual mechanism
Four events, and they differ along an axis that matters more than their names: whether you are asked before it happens, or told while it happens.
platform the event you are… can you cancel? interactive?
android hardware/gesture back asked yes no — it is a callback
ios interactive pop gesture told, live no (once begun) yes — driven by the finger
desktop window close request asked yes no
web popstate (history) told, after no — already moved no
Android's back is a callback with veto power. OnBackPressedDispatcher asks registered
callbacks, in order, and the first one that handles it stops the chain. This is the model
BackHandler is shaped around, and it is the only one of the four with exactly these properties.
iOS's pop gesture is an interactive transition. UINavigationController drives the pop
continuously as the finger moves, with a rubber-banding preview of the previous screen. There is
no "are you sure" point: by the time anything of yours runs, the user has seen the transition and
either completed or cancelled it. The gesture can be disabled or conditionally allowed (via
UIGestureRecognizerDelegate.gestureRecognizerShouldBegin), and that is the real hook — asked
before the gesture starts rather than when it finishes.
Desktop's close is a window-manager request that defaults to terminating the app, and it is
about the window, not the screen. Compose Desktop's onCloseRequest is the veto point, and it has
nothing to do with the navigation stack — a screen-level back on desktop is a keyboard shortcut or a
UI affordance, which is a different event entirely.
Web's popstate fires after the history entry has already changed. You cannot cancel it. The
only ways to resist are to push a sentinel entry so there is something to consume, or to use the
Navigation API's intercept() where available — and our browser matrix did not have it everywhere.
So the single shared BackHandler was not "mostly working". It was modelling one platform's
semantics, and the other three each violate a different assumption inside it: iOS violates "you are
asked", desktop violates "this event is about the screen", web violates "you can cancel".

The fix
Separate the intent from the mechanism: shared code declares whether leaving is currently allowed and what to do when it is not, and each platform wires its own event to that declaration using whatever hook it actually has.
// core/ui/src/commonMain/kotlin/LeaveGuard.kt
/**
* The shared DECLARATION. Not a handler — a statement of policy that each platform's own
* back/close/pop mechanism consults with whatever hook it has.
*
* `canLeave` is consulted BEFORE the mechanism runs where that is possible (android callback,
* desktop onCloseRequest, ios gestureRecognizerShouldBegin) and is the only form that works
* on all four: web's popstate has already happened, so a post-hoc veto is not available to us.
*/
data class LeaveGuard(
val canLeave: () -> Boolean,
val onBlocked: () -> Unit,
)
val LocalLeaveGuard = staticCompositionLocalOf<MutableState<LeaveGuard?>> {
error("no LeaveGuardHost")
}
@Composable
fun GuardLeaving(canLeave: () -> Boolean, onBlocked: () -> Unit) {
val slot = LocalLeaveGuard.current
// rememberUpdatedState, and the effect keyed on Unit. Keying it on the two lambdas looks
// more correct and is worse: a lambda literal is a fresh object on every recomposition,
// so the effect would dispose and re-register the guard continuously — and on web that
// also re-pushes the sentinel history entry each time.
val currentCanLeave by rememberUpdatedState(canLeave)
val currentOnBlocked by rememberUpdatedState(onBlocked)
// Registered for the composition's lifetime, cleared on exit, so a screen that is popped
// normally does not leave a guard behind for the next one.
DisposableEffect(Unit) {
slot.value = LeaveGuard(
canLeave = { currentCanLeave() },
onBlocked = { currentOnBlocked() },
)
onDispose { slot.value = null }
}
}
// feature/editor — the screen declares policy and names no mechanism
@Composable
fun EditorScreen(vm: EditorViewModel, onNavigateUp: () -> Unit) {
var showDiscard by remember { mutableStateOf(false) }
GuardLeaving(canLeave = { !vm.hasUnsavedChanges }, onBlocked = { showDiscard = true })
…
}
// androidMain — the one platform BackHandler was already right for
@Composable actual fun LeaveGuardHost(content: @Composable () -> Unit) {
val slot = remember { mutableStateOf<LeaveGuard?>(null) }
val guard = slot.value
BackHandler(enabled = guard != null && !guard.canLeave()) { guard?.onBlocked?.invoke() }
CompositionLocalProvider(LocalLeaveGuard provides slot) { content() }
}
// iosApp — the real hook is BEFORE the gesture begins, not when it ends.
// Disabling the gesture outright (our second attempt) removes an interaction iOS users expect
// on every screen; shouldBegin lets it stay available and simply not start when a guard says no.
extension HostingController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool {
// not `guard let guard` — `guard` is a keyword, and the shadowing reads badly anyway
guard let leaveGuard = SharedLeaveGuard.current else { return true }
if leaveGuard.canLeave() { return true }
leaveGuard.onBlocked() // show the dialog instead of starting the transition
return false
}
}
// desktopMain — TWO events, because the window and the screen are different things.
fun main() = application {
val slot = remember { mutableStateOf<LeaveGuard?>(null) }
Window(
// The window-manager request. Vetoable, and about the WINDOW: it defaults to quitting
// the app, which is why the original BackHandler-less desktop build lost 328 drafts
// to a close button.
onCloseRequest = {
val g = slot.value
if (g == null || g.canLeave()) exitApplication() else g.onBlocked()
},
) {
// And a screen-level back, which on desktop is a shortcut rather than a system event.
val g = slot.value
Box(Modifier.onPreviewKeyEvent { e ->
if (e.key == Key.Escape && e.type == KeyEventType.KeyDown && g != null && !g.canLeave()) {
g.onBlocked(); true
} else false
}) { CompositionLocalProvider(LocalLeaveGuard provides slot) { App() } }
}
}
// wasmJsMain — popstate cannot be cancelled, so a sentinel entry is pushed to be consumed.
// This is the weakest of the four and is stated as such: the user's back press is spent on
// the sentinel, the dialog appears, and if they confirm we navigate for real.
@Composable actual fun LeaveGuardHost(content: @Composable () -> Unit) {
val slot = remember { mutableStateOf<LeaveGuard?>(null) }
val guard = slot.value
// Keyed on the BOOLEAN, not on the guard object: the guard's identity changes whenever
// the screen recomposes, and keying on it pushed a fresh sentinel each time — a history
// stack that grew until back did nothing at all for several presses.
val blocking = guard != null && !guard.canLeave()
LaunchedEffect(blocking) {
if (blocking) {
window.history.pushState(null, "", window.location.href) // the sentinel
}
}
DisposableEffect(Unit) {
val l: (Event) -> Unit = {
val g = slot.value
if (g != null && !g.canLeave()) {
window.history.pushState(null, "", window.location.href) // re-arm
g.onBlocked()
}
}
window.addEventListener("popstate", l)
onDispose { window.removeEventListener("popstate", l) }
}
CompositionLocalProvider(LocalLeaveGuard provides slot) { content() }
}
And the output it produced, two weeks:
$ ./tools/leave-guard-report.sh --days 14
platform sessions with unsaved changes dialog shown drafts lost
android 1,790 1,790 0
ios 1,164 1,151 13 (gesture already in flight)
desktop 318 318 0
web 861 847 14 (tab closed outright)
2,387 lost drafts → 27. The 27 are two cases the mechanisms genuinely cannot reach, named below.
How we proved it
That the iOS callback never fired. The measurement that separated "the dialog is broken" from "the handler is not invoked":
BackHandler(enabled = true) { log("back handler invoked") }
// edge-swipe to pop, 20 times
// android: 20 log lines
// ios: 0 log lines
Zero. The handler was not failing a condition; it was never called.
That the gesture is interactive rather than a callback. Confirmed by instrumenting the transition, because this is the fact that rules out a post-hoc veto:
finger down at the edge UINavigationController begins the pop
finger at 30% across previous screen visible, rubber-banding
finger released at 30% transition CANCELLED, no app code involved
finger released at 70% transition COMPLETED, no app code involved
There is no point in that sequence at which anything of ours is asked.
That shouldBegin is asked before it. The hook that makes the fix possible, verified in the same
instrumentation:
gestureRecognizerShouldBegin → false no transition starts; the screen does not move
gestureRecognizerShouldBegin → true the sequence above
That desktop's close is about the window. Measured because it explains 328 of the lost drafts and is not a navigation bug at all:
action what happened (before)
window close button app exited; unsaved work gone
Cmd+[ (wired by hand in one build) dialog shown — all 12 of desktop's dialogs
Escape key nothing — not wired
a Back button in the UI nothing — the screen had none
That beforeunload does not fire for in-page history. Checked, since it is the standard web
answer and it is the wrong event here:
action beforeunload popstate
browser back (in-app SPA) no yes
close the tab yes no
reload yes no
Two disjoint events, and we had wired the one the bug does not use.
That the sentinel-entry approach costs the user a back press. Stated because it is a real downside of the only web mechanism available to us:
user presses back once sentinel consumed, dialog shown, URL unchanged
user confirms discard we navigate for real
user presses back twice fast dialog shown once, second press consumed by the re-armed sentinel
What I cannot tell you is whether the Navigation API's intercept() would be strictly better on
web. It is the right-shaped primitive — a genuine veto point before the navigation commits — and our
supported-browser matrix did not have it everywhere when we shipped this, so we have not run it in
production.
And the 27 remaining losses are honest. Thirteen on iOS are gestures already in flight when the
guard became active (the user began the swipe in the same frame as a keystroke); fourteen on web are
tab closes, where beforeunload does fire and the browser's own generic dialog appears — we chose
not to add a second dialog on top of it.

What this generalizes to
When four platforms expose "the same" user action, the differences that matter are usually not in the API shape but in the timing of the question: whether you are asked before the thing happens, told while it happens, or informed after it already has. A single shared handler encodes one of those three, silently, and fails on every platform with a different one. The portable move is to have shared code declare a policy — is leaving allowed, and what happens if not — and let each platform bind its own event to it, including admitting where the platform offers no question at all.
Related
- KMP navigation has no Fragment — the stack this back event pops, and who owns it per platform.
- Side effects have a lifecycle — why the
DisposableEffectregistration above clears on exit rather than on recomposition.
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".