Contents

SugarLite KMP in Production: A 3-Second-After-Launch Crash and the Cost of Uncaught viewModelScope Exceptions

When we closed Part 3, we said the post-migration routine is “new features default to KMP-first.” That’s true — but it hides a premise: shared-layer code does not run in equal environments on the two platforms. The same Kotlin code that survives as a catchable exception on Android can kill the entire process on iOS.

This post documents a real incident: the iOS build of SugarLite crashed with SIGABRT exactly 3 seconds after every cold start, with a stack pointing at an unfamiliar symbol — propagateExceptionFinalResort. The root cause wasn’t a single line of Swift. It was a handful of innocent-looking Flow subscriptions in our shared ViewModels — their exceptions were never caught, and on Kotlin/Native that means the process dies.

The symptom was eerily consistent: cold-launch the iOS app, the home page starts loading data, and roughly 3 seconds in, the app disappears. Every device, every OS version, every single time.

What the crash reports told us:

  • The signal was SIGABRT — not the usual SIGSEGV / SIGTRAP;
  • The bottom of the stack showed propagateExceptionFinalResort — not our code, not the Swift runtime, but the Kotlin/Native coroutine runtime’s last resort;
  • The same version on Android, with the same data, never crashed.

“Crashes on iOS, fine on Android” rules out ordinary logic bugs (null pointers, index overflows) and points at a platform-runtime behavioral difference. And the name propagateExceptionFinalResort already confesses everything: an exception propagated all the way to the top of a coroutine, nobody caught it, and the runtime killed the process.

Recall the KMP ViewModel pattern from Part 3:

class HomeViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(viewModelScope, HomeUiState())

    init {
        viewModelScope.launch { observeDailyData() }      // daily data
        viewModelScope.launch { observeWeekData() }       // weekly data
        viewModelScope.launch { observeBloodSugarUnit() } // unit preference
    }
}

Inside observeDailyData() sits a textbook reactive chain: combine over 5 database Flows, flatMapLatest for date switching, and a final collect into UiState. The problem hides in that chain — if any upstream stage throws during collection (a failed database query, a network hiccup, a deserialization error), the exception propagates up the coroutine tree.

On Android, such an exception eventually reaches Thread.uncaughtExceptionHandler — an ordinary JVM crash, fully captured by your crash SDK, with the occasional lucky fallback. But two fatal differences apply here:

  1. KMP-ObservableViewModel’s viewModelScope installs no CoroutineExceptionHandler. It’s a bare SupervisorJob + Dispatchers.Main; an exception reaching the root has no handler at all.
  2. Kotlin/Native’s default response to “uncaught exception at coroutine root” is propagateExceptionFinalResort — the name says it literally: call abort() on the main thread, deliver SIGABRT, process dies instantly. No JVM stack trace, no graceful crash-SDK reporting. Execution by firing squad.

The full chain of events:

Room / network layer throws
  → nobody catches it inside Flow collection
  → exception climbs the Job tree to the viewModelScope root
  → no CoroutineExceptionHandler installed at the root
  → Kotlin/Native: propagateExceptionFinalResort
  → abort() → SIGABRT → process gone

Why exactly 3 seconds after launch? Because HomeViewModel fires all three Flow subscriptions in init, and database queries and cache reads cluster in the startup window — the first round of Flow emissions lands around the 3-second mark, and that’s when the exception detonates.

This also explains why the incident only appeared after the migration: before, these subscriptions lived in iOS-side StateHolders, wrapped in Swift do/catch around the for await loops. When the logic sank into shared, the safety nets didn’t sink with it.

The fix is deliberately unglamorous: wrap every Flow collection and business call inside viewModelScope.launch with try/catch. Using observeDailyData() as the example:

private suspend fun observeDailyData() {
    // viewModelScope has no CoroutineExceptionHandler; any exception escaping
    // a Flow collection aborts the iOS process via propagateExceptionFinalResort
    try {
        combine(
            _uiState.map { it.selectedDate }.distinctUntilChanged(),
            currentUserIdFlow(),
        ) { date, uid -> date to uid }
            .flatMapLatest { (date, uid) ->
                combine(
                    getDailyBloodSugarRecordsFlow(dateTs, uid),
                    getDailyExerciseRecordsFlow(dateTs, uid),
                    getDailyFoodRecordsWithItemsFlow(dateTs, uid),
                    getDailyMedicationRecordsFlow(dateTs, uid),
                    getDailyInsulinRecordDtosFlow(dateTs, uid),
                ) { bsDtos, exDtos, foodDtos, medDtos, insulinDtos ->
                    enrichExerciseReferences(exDtos)
                    assembleStateChunk(bsDtos, exDtos, foodDtos, medDtos, insulinDtos)
                }
            }
            .collect { chunk ->
                _uiState.update { state -> chunk(state) }
            }
    } catch (e: CancellationException) {
        throw e  // cancellation semantics must propagate unchanged
    } catch (e: Exception) {
        AppLogger.e("HomeViewModel", e) { "Failed to observe daily data" }
        _uiState.update { it.copy(isLoading = false, errorMessage = "Load failed: ${e.message}") }
    }
}

Three points, none of them optional:

  1. CancellationException must be rethrown. Coroutine cancellation works by throwing CancellationException; swallowing it in catch (e: Exception) means subscriptions keep running after the view dies — precisely the leak we eliminated in Part 3. It gets its own catch clause, rethrown verbatim, preserving cancellation semantics.
  2. Log it, and write it into UiState. The exception no longer escapes, but it must not be silently swallowed either — AppLogger.e preserves the debugging trail, while isLoading = false plus errorMessage turn the failure into UI state. The user sees “load failed” instead of the entire app vanishing. That single catch is the entire distance between a crash and an error message.
  3. The catch belongs outside collect, not around every upstream Flow. Any stage of the combine / flatMapLatest chain propagates its exception to the collecting coroutine, so one outer try/catch catches everything — no need to wrap each upstream Flow individually.

The fix covered 5 ViewModels in total:

FileWhere the catch was addedDiff size
HomeViewModel.ktThree startup subscriptions: daily data, weekly data, unit preference+67 -44
ProfileViewModel.ktUser-access subscription, profile subscription+40 -23
AIFoodScanViewModel.ktAI recipe-scan suspend call+32 -22
AddFoodRecordViewModel.ktAI meal-recognition submission+28 -19
AddMedicationRecordViewModel.ktMedication-name subscription+17 -8

The pattern is identical everywhere: try wraps the entire subscription or call, CancellationException is rethrown, everything else is logged and written to errorMessage. No exceptions, no variants.

The obvious first idea: if the root lacks a handler, just install a global CoroutineExceptionHandler on viewModelScope and be done with it.

// Looks elegant — we didn't choose this path
val handler = CoroutineExceptionHandler { _, throwable ->
    AppLogger.e("VM", throwable) { "Uncaught exception" }
}

Because CoroutineExceptionHandler solves “the process survives,” but not “what the user sees”:

  • It has no context. By the time an exception reaches the root handler, all you get is a Throwable and a CoroutineContext — no idea which subscription died, which page the user is on, or which UiState should receive an errorMessage. The process survives, but the page spins on loading forever.
  • It does nothing for async. Exceptions in async are captured into the Deferred and only surface at await() — they never pass through the root handler.
  • It’s a last resort, not business logic. Pushing “load failed, please retry” — state that belongs in UiState — into a global handler detaches error handling from single-track state management, the very foundation of our architecture: UiState is the only source of truth for the UI.

The value of local try/catch is precisely that it sits at the scene of the failure: it knows which operation failed, can translate the exception into that page’s errorMessage, and can reset isLoading. A global handler is the safety net for “in case we missed one”; local catches answer “what happens when this specific operation fails.” The two don’t conflict — but the latter is non-negotiable.

After the fix, we turned the lessons into coding conventions for the shared layer:

  1. Every viewModelScope.launch that collects a Flow must have a try/catch floor. This was the direct root cause. Flows sit atop databases, networks, and platform bridges — all untrusted boundaries. Exceptions will happen; the only question is on whose device.
  2. catch (e: CancellationException) { throw e } is always the first catch clause. Cancellation semantics are the lifeblood of coroutines; swallowing them creates leaks. This goes first, every time, until it’s muscle memory.
  3. Exceptions have exactly two exits: AppLogger and UiState.errorMessage. Never silently swallowed, never allowed to escape the coroutine. User-visible errors ride the UiState single track; debugging details go to the log — consistent with the architecture-wide principle that UiState is the single source of truth.

In hindsight, this class of bug is a systemic trap in KMP shared layers: the fault-tolerance habits Kotlin code acquires on Android do not transfer to iOS. The JVM world has a mature crash-capture apparatus underneath, so plenty of “never caught, never mattered” code survives there. Kotlin/Native’s propagateExceptionFinalResort has zero tolerance for that code — uncaught means executed. Every layer that sinks into shared deserves one more degree of respect for these platform behavior differences.


SugarLite is a blood-glucose management app that helps people with diabetes and anyone tracking their glucose record and analyze blood sugar, meals, exercise, medication, and insulin — with intelligent features like AI meal recognition, AI recipes, and statistical reports. Built with Kotlin Multiplatform on both iOS and Android, with business logic and state management fully shared.


This post is based on a real production incident and its investigation in the SugarLite project.

Related Content