Coroutine Exceptions & Error Handling Interview Questions

KOTLIN › Coroutines

A crash report shows an exception from a coroutine but the stack trace stops at the dispatcher, with none of your code in it. How do you approach that?

What a strong answer covers: That shape is normal, because the stack at the point of failure has nothing to do with the stack that launched the coroutine: the coroutine resumed on a pool thread with a fresh stack. First I would turn on the debug agent with -Dkotlinx.coroutines.debug, which stamps coroutine names into thread names, and give the important coroutines a CoroutineName so the report says which one died rather than which pool thread it died on. Second I would check what we are actually logging. If the handler logs e.message or even e.toString we lose two things: the suppressed array, which is where sibling failures end up when several children fail together, and the cause chain. Logging stackTraceToString instead recovers both. Third, if this is reproducible in development, DebugProbes.dumpCoroutines gives a live picture of every coroutine and where it is suspended, which is usually faster than reasoning about it. The deeper fix is usually that the exception should not have reached the handler at all. If it is an IOException from a network call, the repository should be turning it into a Result at the boundary, so the ViewModel gets a value it can render rather than a crash we have to reconstruct from a log.

Explain why a CoroutineExceptionHandler installed on a nested launch does nothing, and where it should go instead.

What a strong answer covers: Because a handler is consulted only for a root coroutine, meaning one whose failure has nowhere else to go. A nested launch has a parent Job that is willing to take the failure, so the runtime reports upward rather than handling locally, and the handler sitting in that child context is simply never asked. The failure then propagates to the scope root, and if nothing there has a handler it reaches the thread default handler and crashes. The right placement is on the scope itself, CoroutineScope(SupervisorJob() + Dispatchers.Main + handler), so every coroutine started from it is covered by one policy. The SupervisorJob matters as much as the handler: with a regular Job the first failure cancels the scope permanently and every later launch on it silently does nothing, which is a nasty bug because the screen just stops responding rather than crashing. The one exception to the nesting rule is supervisorScope, where each direct child effectively becomes a root because the supervisor declines the failure, so a handler on a direct child does fire there. That is the detail that makes the two cases look contradictory until you know the rule is about roots rather than about depth.

Your app has a bug where the user leaves a screen mid-request and the network call retries three more times anyway. What is the likely cause?

What a strong answer covers: Almost certainly a broad catch swallowing CancellationException. Cancellation is delivered by resuming the coroutine with a CancellationException so its finally blocks run and it unwinds. Any catch (e: Exception), and runCatching in particular since it catches Throwable, matches that and treats it as an ordinary failure. In a retry loop that means cancellation looks exactly like a retryable network error, so the loop sleeps and tries again on a screen that no longer exists. The fix is to put a CancellationException clause first that rethrows, before the broader clause, in every retry helper and every Result wrapper. I would grep the codebase for runCatching around suspending calls, because that is the common source: it reads as harmless and is not. There is a second symptom worth checking for at the same time, which is state updates arriving after onCleared. Same root cause, and it usually shows up as either a leak warning or a crash from touching a released binding.

When would you deliberately use supervisorScope over coroutineScope, and what does it not protect you from?

What a strong answer covers: When the children are genuinely independent and a partial result is still useful. Firing several unrelated analytics calls, or loading three optional widgets on a dashboard where one failing should still leave the other two rendered. coroutineScope is right for the opposite case, parallel decomposition where the result is meaningless unless every part succeeds, because there the all-or-nothing cancellation is a feature: you do not want two of three network calls continuing to burn battery when the screen can never render. What supervisorScope does not protect you from is depth. It isolates direct children only. If a direct child launches its own children and one of those throws, that failure goes to the ordinary Job of the direct child, and the whole inner group cancels together. The supervisor stops it spreading sideways to the outer siblings, but the subtree is not tolerant. It also does not make the failure disappear: an isolated child still needs a handler or a try/catch, or the exception reaches the default uncaught handler and crashes the app just the same. And it has nothing to say about async, whose exception is stored for await regardless of what kind of Job is above it.

How would you design error handling across a repository and ViewModel so failures cannot be forgotten?

What a strong answer covers: Make failure part of the type at the layer boundary. The repository catches the exceptions it knows about and returns a value: Result at minimum, but I would usually prefer a domain sealed type, something like Success, NetworkUnavailable, Unauthorised, NotFound. The reason to prefer the sealed type over Result is that it forces the caller to think about each case rather than collapsing everything into one error string, and it keeps HTTP and IO concepts out of the ViewModel. Then the ViewModel maps that to UI state, which is a total function with no try/catch anywhere in it. The one trap in implementing this is runCatching, because it catches Throwable and therefore swallows CancellationException, which quietly breaks cancellation. So the repository uses a small helper that rethrows CancellationException first and only converts real exceptions into failures. Above all that I would still install a CoroutineExceptionHandler on the application scope, but purely as a crash reporter for the failures nobody anticipated. The distinction I would want to articulate is that the handler is where you find out you had a bug, not where you handle a case you designed for.

Walk me through what happens to the other calls when one async in a parallel load fails.

What a strong answer covers: Inside coroutineScope, the failing async reports to the scope Job immediately, at the moment it throws, not when someone awaits it. The scope cancels its remaining children, so the other async calls are cancelled mid-flight, and then the scope rethrows the original exception to whoever called the enclosing suspend function. So the caller sees one exception and no partial results, and the cancelled calls stop consuming network and CPU straight away, which is the behaviour you want when the screen needs all three pieces. The nuance is that this happens regardless of await. People expect async to hold its exception until awaited, and that is only true for a root async with no parent watching it, like GlobalScope.async. Inside a real scope the parent is always watching. If I wanted one of the three to be optional, catching around the awaitAll would not help, because the scope is already cancelling by then. I would have to isolate that call at its source, either async { runCatching { api.ads() }.getOrNull() } so it can never fail its parent, or run it in a supervisorScope so its failure does not propagate sideways.

Back to Coroutine Exceptions & Error Handling