Luis Daivid
05/20/2026, 5:54 AMNSExceptionKt installed (the setUnhandledExceptionHook-based bridge to Crashlytics). On the main thread it works beautifully â Kotlin throwables get converted to NSException and Crashlytics captures full Kotlin frames, class names, messages, the works.
The worry
Our reading of the docs / source is that setUnhandledExceptionHook only fires for exceptions that reach specific Kotlin boundaries (main runloop, ObjC export boundary). When an unhandled exception happens on Dispatchers.Default / <http://Dispatchers.IO|Dispatchers.IO> / a worker thread, the runtime apparently calls konan::abort() directly from the worker, bypassing the hook entirely.
The resulting Crashlytics report looks like this:
SIGABRT
konan::abort
ExceptionObjHolderImpl::terminateWithUnhandledException
BaseContinuationImpl.resumeWith
CoroutineScheduler$Worker.run
No exception class name, no message, no Kotlin frames â and worst of all, every kind of unhandled (NPE / ISE / OOBE / anything) collapses into a single issue group in the dashboard.
Why this is scary
Most of our domain/data layer (repositories, use cases, Ktor responses, DataStore I/O) runs on <http://Dispatchers.IO|Dispatchers.IO>. If the above is correct, a huge portion of production crashes would be effectively undebuggable, with misleading "100% of users hit this one issue" stats.
Two questions:
1. _Is this actually the case in practice?_Has anyone empirically verified that unhandled exceptions on background dispatchers are captured poorly (or not at all) by Crashlytics on KMP iOS, even with NSExceptionKt installed?
2. _If yes â how are you handling it in production?_Patterns we're considering:
⢠Installing a`CoroutineExceptionHandler`on every scope that bridges the throwable back to the main thread before re-throwing (so the hook fires)
⢠A base class for repositories / use cases with try/catch + non-fatal reporting (recordException)
⢠Switching to Sentry KMP / Kotzilla / another SDK that handles worker-thread crashes natively
⢠Something else entirely (CrashKiOS, Kermit, custom solutionâŚ)François
05/20/2026, 6:01 AMFrançois
05/20/2026, 6:02 AMFrançois
05/20/2026, 6:04 AMFrançois
05/20/2026, 6:04 AMLuis Daivid
05/20/2026, 6:08 AMFrançois
05/20/2026, 6:12 AMFrançois
05/20/2026, 6:13 AMFrançois
05/20/2026, 6:15 AMRick Clephas
05/20/2026, 6:19 AMFrançois
05/20/2026, 6:21 AMFrançois
05/20/2026, 6:23 AMLuis Daivid
05/20/2026, 10:12 AMRick Clephas
05/20/2026, 10:18 AMFrançois
05/20/2026, 10:24 AMFrançois
05/20/2026, 10:25 AMLuis Daivid
05/20/2026, 10:28 AM// All three buttons throw on the Compose main thread (DarwinMainDispatcher).
// With NSExceptionKt installed, I expected the same well-formed Crashlytics
// report for all three â Kotlin exception class, message, and KMP frames.
//
// What actually happens on Kotlin 2.3.21 + CMP 1.11.0:
// 1.2 / 1.3 â works as expected (Kotlin class name, message, full stack)
// 1.1 â collapses into `(anonymous namespace)::ExceptionObjHolderImpl`
// with no Kotlin frames, identical to issue #51
//
// The only structural difference of 1.1 is that the throw happens
// synchronously inside the onClick lambda (no coroutine in between).
@Composable
fun CrashRepro() {
val scope = rememberCoroutineScope()
Column {
// 1.1 â synchronous throw from onClick â BROKEN report
Button(onClick = { fatalGeneralStep1() }) {
Text("1.1 throw from plain function (onClick)")
}
// 1.2 â same main thread, but wrapped in scope.launch â OK
Button(onClick = { scope.launch { fatalSuspendStep1() } }) {
Text("1.2 throw from suspend function")
}
// 1.3 â same main thread, throw inside Flow operator â OK
Button(onClick = { scope.launch { fatalFlowStep1() } }) {
Text("1.3 throw inside Flow operator")
}
}
}
// ----- 1.1 (broken in Crashlytics) -----
private fun fatalGeneralStep1() = fatalGeneralStep2()
private fun fatalGeneralStep2() = fatalGeneralStep3()
private fun fatalGeneralStep3() {
error("Test crash from general function")
}
// ----- 1.2 (reported correctly) -----
private suspend fun fatalSuspendStep1() {
delay(10)
fatalSuspendStep2()
}
private suspend fun fatalSuspendStep2() {
delay(10)
fatalSuspendStep3()
}
private fun fatalSuspendStep3() {
require(false) { "Test crash from suspend function" }
}
// ----- 1.3 (reported correctly) -----
private suspend fun fatalFlowStep1() {
flow {
emit(1)
emit(2)
emit(3)
}
.map { fatalFlowStep2(it) }
.collect()
}
private fun fatalFlowStep2(value: Int): Int = fatalFlowStep3(value)
private fun fatalFlowStep3(value: Int): Int {
if (value == 3) {
throw ArithmeticException("Test crash from flow operator")
}
return value
}Rick Clephas
05/20/2026, 10:29 AMGiancarlo Buenaflor
05/20/2026, 10:32 AMFrançois
05/20/2026, 10:39 AMGiancarlo Buenaflor
05/20/2026, 10:47 AMGiancarlo Buenaflor
05/20/2026, 10:47 AMRick Clephas
05/22/2026, 6:49 PMLuis Daivid
05/28/2026, 5:16 AMfinal class AppDelegate: NSObject, UIApplicationDelegate {
// :warning: non-lazy stored properties that touch KMP symbols
private let loginManager = LoginManager.instance // Kotlin object
private let crashlyticsLogger = FirebaseCrashlyticsLogger() // conforms to a Kotlin protocol
// ...a few more like this
func application(_: UIApplication, didFinishLaunchingWithOptions _: ...) -> Bool {
FirebaseApp.configure()
NSExceptionKt.addReporter(.crashlytics(causedByStrategy: .append))
// ...register KMP holders
startKoinAndOtherKmpInit()
return true
}
}
The intended order in didFinishLaunchingWithOptions is FirebaseApp.configure() â NSExceptionKt.addReporter(.crashlytics) â rest of KMP init, which matches the "correct" ordering you described.
But based on your theory, our setup is probably broken too. The AppDelegate has non-lazy stored properties that reference KMP symbols (a Kotlin object, a class that conforms to a Kotlin protocol, etc.). Swift evaluates non-lazy stored properties when the owning instance is constructed, so the Kotlin runtime is almost certainly initialized inside AppDelegate.init() â before FirebaseApp.configure() ever runs. Net result: the Kotlin terminate handler is installed first, Crashlytics on top of it â exactly the bad ordering.
Two questions:
1. If that diagnosis is right, is the recommended fix to move`FirebaseApp.configure()`earlier â e.g. into`iOSApp.init()`or an ObjC`+initialize`â so it runs before_any_Kotlin symbol is touched?
2. Or does`NSExceptionKt`expose a way to control the Kotlin-side handler registration order from the Kotlin side?Rick Clephas
05/28/2026, 6:47 AMdidFinishLaunchingWithOptions function
2. No (at least not right now). This might be quite difficult. The issue here is in the C++ exception handler set by the Kotlin runtime and Crashlytics. While the Crashlytics initialisation is know, the point at which the Kotlin runtime initialises isnât know. E.g. in your example it would be one of those stored properties. To be able to control the order we would need to register a handler before any other handler is set.
I have created an issue for this: https://youtrack.jetbrains.com/issue/KT-86576/K-N-C-terminate-handler-setup-order-issueLuis Daivid
07/01/2026, 5:00 AMRick Clephas
07/01/2026, 5:26 AMLuis Daivid
07/01/2026, 5:32 AMRick Clephas
07/01/2026, 5:39 AMLuis Daivid
07/01/2026, 5:39 AM