Four Kotlin Coroutine Mistakes That Reach Production
Work that keeps running after a screen closes, cancellation swallowed by a catch block, exceptions that vanish, and flows collected while the app is in the background.
Coroutines are easy to start and easy to get subtly wrong. These four mistakes pass code review, pass QA, and show up later as a leak, a crash with no stack trace you recognise, or a bill from your API provider.
1. Launching in a scope that never ends
GlobalScope.launch {
repository.refreshCourses()
}
GlobalScope lives as long as the process. Close the screen and the work carries on: the network call completes, the result is written somewhere, and anything it captured stays in memory. Rotate the device a few times and you have several copies running.
Use a scope tied to the thing that needs the result:
class CourseViewModel(private val repository: CourseRepository) : ViewModel() {
fun refresh() = viewModelScope.launch {
repository.refreshCourses()
}
}
viewModelScope is cancelled when the ViewModel is cleared, lifecycleScope when the lifecycle owner is destroyed. If a piece of work must outlive the screen on purpose, that is a job for WorkManager, not for a scope with no owner.
2. Catching cancellation
This looks like defensive programming and quietly breaks the framework:
try {
val courses = api.fetchCourses()
show(courses)
} catch (e: Exception) { // also catches CancellationException
showError(e)
}
Cancellation in coroutines is delivered as an exception. When the user leaves the screen, the scope cancels the job by throwing CancellationException inside it. Catch that and you tell the machinery the coroutine handled it, so the coroutine carries on, and your user sees an error message for a screen they already left.
Catch what you mean, or rethrow cancellation:
try {
show(api.fetchCourses())
} catch (e: CancellationException) {
throw e // let the cancellation finish
} catch (e: IOException) {
showError(e)
}
The same applies to runCatching, which catches Throwable and has exactly this problem inside coroutines.
3. Expecting launch and async to fail the same way
They do not.
launchthrows into its parent immediately. In an unsupervised scope that cancels the siblings and reaches the scope's exception handler, which on Android often means a crash.asyncholds the exception in itsDeferreduntil you callawait(). If nothing ever awaits it, nothing is ever reported, and the failure disappears.
viewModelScope.launch {
val courses = async { api.fetchCourses() }
val sessions = async { api.fetchSessions() }
show(courses.await(), sessions.await()) // both failures surface here
}
Two more rules that save hours: a CoroutineExceptionHandler works at the top-level coroutine of a scope, not on a nested async; and SupervisorJob stops one failed child from cancelling its siblings, which is what you want for a screen loading several independent things.
4. Collecting a flow while the app is in the background
A plain collect in lifecycleScope.launch keeps collecting while the user is in another app. For a location or sensor flow that is battery you are spending for nothing; for a flow backed by a socket it is a connection held open.
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.courses.collect { render(it) }
}
}
repeatOnLifecycle cancels the collection when the lifecycle drops below STARTED and starts a new one when it comes back. In Compose, collectAsStateWithLifecycle() does the same thing for you; collectAsState() does not.
5. Never testing the cancelled path
Every mistake above is invisible in a test that always runs to completion. Two habits catch them early.
First, inject the dispatcher instead of hard-coding it, so a test can run the work on a scheduler it controls:
class CourseViewModel(
private val repository: CourseRepository,
private val io: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
fun refresh() = viewModelScope.launch {
withContext(io) { repository.refreshCourses() }
}
}
Second, write one test that cancels mid-flight and asserts that nothing was written afterwards:
@Test
fun cancelledRefreshWritesNothing() = runTest {
val job = launch { viewModel.refresh() }
advanceTimeBy(50)
job.cancelAndJoin()
advanceUntilIdle()
assertTrue(repository.writes.isEmpty())
}
runTest gives you virtual time, so a delay of thirty seconds costs nothing and a timeout is testable. If your production code caught CancellationException, this test fails: the write happens after the cancel. That is the bug from section two, found on a laptop instead of in a crash report.
A note on dispatchers
Two habits worth keeping. First, a suspend function should be safe to call from the main thread; it is the function's own job to move to a background dispatcher with withContext, not the caller's. Second, Dispatchers.IO is sized for waiting, not for computing: parsing a large response or resizing a bitmap belongs on Dispatchers.Default.
suspend fun loadCourses(): List<Course> = withContext(Dispatchers.IO) {
val body = api.fetchCourses()
withContext(Dispatchers.Default) { parseCourses(body) }
}
How to catch these before users do
Turn on strict mode in debug builds, and write one test that cancels a scope mid-work and asserts nothing was written afterwards. Most of these bugs are invisible on a fast phone with a fast network, and obvious the moment the network is slow and the user is impatient, which is exactly when your app is being judged.