Skip to main content

Coroutines Deep Dive

The overview showed you viewModelScope.launch { }. This chapter covers what happens inside that call — the coroutine machinery senior engineers must understand to debug real production code.

Structured concurrency

A coroutine never exists alone. Every launch or async creates a child of the enclosing scope's Job. If the parent cancels, all children cancel. If a child throws, the parent cancels (and by transitivity, siblings cancel).

// The root scope of this work is viewModelScope.
// Launching three children here means:
// - If the ViewModel is cleared → all three cancel
// - If any child throws → all three cancel (default Job)
viewModelScope.launch {
val user = async { userRepo.fetch(id) }
val posts = async { postsRepo.listBy(id) }
val followers = async { followRepo.countFollowersOf(id) }
_state.value = ProfileUi(
user = user.await(),
posts = posts.await(),
followers = followers.await()
)
}

SupervisorJob — isolated failure

Sometimes you want sibling coroutines to fail independently. Use SupervisorJob or supervisorScope { }:

// If `posts` fetch fails, `followers` still succeeds.
viewModelScope.launch {
supervisorScope {
val user = async { userRepo.fetch(id) }
val posts = async { runCatching { postsRepo.listBy(id) }.getOrDefault(emptyList()) }
val followers = async { runCatching { followRepo.countFollowersOf(id) }.getOrNull() }
_state.value = ProfileUi(user.await(), posts.await(), followers.await())
}
}

Dispatchers — what runs where

Threads and dispatchers
JS THREAD{ }JavaScriptReact renderState updatesEvent handlersBusiness logicSHADOW THREADYoga LayoutFlexbox calcWidth / HeightPositioningMeasurementsUI THREADNative UIRenderingAnimationsGestures60–120 fps
Dispatchers are thread pools with different behaviors. Pick the one that matches the work.
DispatcherBacking threadsUse for
Dispatchers.MainAndroid main threadUI updates, calling suspend fun that returns to UI
Dispatchers.Main.immediateCurrent main thread if already on itAvoid unnecessary post to message queue
Dispatchers.IOShared pool, 64+ threadsDisk, network, blocking JDBC, JNI
Dispatchers.DefaultCPU-count threadsCPU-bound work: JSON parsing, image encoding
Dispatchers.UnconfinedWhatever thread resumed the continuationAdvanced; avoid in production
// WRONG — blocking on Main
class UserViewModel : ViewModel() {
fun load(id: String) {
viewModelScope.launch {
val bytes = File("/sdcard/cache/$id").readBytes() // ⚠️ BLOCKS MAIN
_state.value = UiState.Success(bytes)
}
}
}

// RIGHT — switch to IO for blocking work
fun load(id: String) {
viewModelScope.launch {
val bytes = withContext(Dispatchers.IO) { File("/sdcard/cache/$id").readBytes() }
_state.value = UiState.Success(bytes)
}
}

Inject dispatchers for testability

Never reference Dispatchers.IO directly in code you want to test:

// Production module
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultDispatcher

@Module @InstallIn(SingletonComponent::class)
object DispatcherModule {
@Provides @IoDispatcher fun io(): CoroutineDispatcher = Dispatchers.IO
@Provides @DefaultDispatcher fun default(): CoroutineDispatcher = Dispatchers.Default
}

// Repository
class ImageRepository @Inject constructor(
@IoDispatcher private val io: CoroutineDispatcher
) {
suspend fun compress(bytes: ByteArray): ByteArray = withContext(io) {
/* heavy work */
}
}

// Test
class ImageRepositoryTest {
private val dispatcher = StandardTestDispatcher()
private val repo = ImageRepository(dispatcher)

@Test fun `compress completes synchronously in test`() = runTest(dispatcher) {
val result = repo.compress(ByteArray(1024))
assertNotNull(result)
}
}

Cancellation — the cooperative model

Cancellation in coroutines is cooperative. A coroutine checks for cancellation at suspension points; tight CPU loops ignore it.

// BROKEN — won't cancel until the loop finishes
viewModelScope.launch {
var x = 0
while (true) { x++ } // never suspends → never checks for cancellation
}

// RIGHT — ensureActive() inside tight loops
viewModelScope.launch {
var x = 0
while (true) {
ensureActive() // throws CancellationException if cancelled
x++
}
}

// Or use isActive
viewModelScope.launch {
while (isActive) { /* work */ }
}

CancellationException is special

viewModelScope.launch {
try {
doWork()
} catch (e: Exception) {
_error.value = e.message // ⚠️ swallows CancellationException
}
}

// RIGHT — re-throw cancellation
viewModelScope.launch {
try {
doWork()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_error.value = e.message
}
}

// BETTER — Kotlin 1.6+ gives us a helper
viewModelScope.launch {
runCatching { doWork() }
.onFailure { if (it is CancellationException) throw it else _error.value = it.message }
}

NonCancellable — atomic cleanup

When you MUST complete an operation (finalize a DB transaction, close a file) even after cancellation, wrap it in NonCancellable:

suspend fun replaceCache(data: ByteArray) {
try {
writeNewCache(data)
} finally {
withContext(NonCancellable) {
oldCacheFile.delete() // runs even if parent cancels
}
}
}

Exception propagation

Exceptions in launch are uncaught by the caller unless a CoroutineExceptionHandler is installed. Exceptions in async surface only when you .await().

// `launch` — fails silently to the caller; propagates to parent job
viewModelScope.launch { throw IOException() } // caller does NOT see it

// With a handler
val handler = CoroutineExceptionHandler { _, e -> Firebase.crashlytics.recordException(e) }
viewModelScope.launch(handler) { /* ... */ }

// `async` — exception rethrown on await
viewModelScope.launch {
val deferred = async { throw IOException() }
runCatching { deferred.await() }.onFailure { /* handle */ }
}

CoroutineExceptionHandler placement

// WRONG — handler on the child, not the launch target
viewModelScope.launch {
launch(handler) { throw IOException() } // handler doesn't help; parent still cancels
}

// RIGHT — supervisorScope isolates, handler catches
viewModelScope.launch {
supervisorScope {
launch(handler) { throw IOException() }
launch { doOtherWork() } // still runs
}
}

Channels and Flows

Channel — hot, coroutine-to-coroutine pipe

class UploadQueue {
private val channel = Channel<Upload>(capacity = Channel.BUFFERED)

suspend fun enqueue(upload: Upload) = channel.send(upload)

fun startWorker(scope: CoroutineScope) = scope.launch {
for (upload in channel) {
uploadService.send(upload)
}
}
}

Flow — cold, declarative stream

fun searchUsers(query: String): Flow<List<User>> = flow {
emit(emptyList()) // initial
val results = api.search(query)
emit(results)
}
.flowOn(Dispatchers.IO) // upstream runs on IO
.catch { emit(emptyList()) } // convert errors
.distinctUntilChanged() // drop duplicate emissions

Combining flows the right way

// Combine — re-emit whenever ANY source emits
val uiState: Flow<UiState> = combine(
userFlow,
settingsFlow,
connectivityFlow
) { user, settings, connected ->
UiState(user, settings, connected)
}

// Zip — re-emit only when ALL have new values
val paired: Flow<Pair<A, B>> = flowA.zip(flowB) { a, b -> a to b }

// flatMapLatest — cancel the old inner flow when outer emits
val searchResults: Flow<List<User>> = queryFlow
.debounce(300)
.flatMapLatest { query -> searchUsers(query) } // cancels in-flight on new query

Testing coroutines

kotlinx-coroutines-test is not optional. Master three tools:

@ExtendWith(MainDispatcherRule::class)
class SearchViewModelTest {
private val dispatcher = StandardTestDispatcher()

@Test fun `debounce waits 300ms`() = runTest(dispatcher) {
val vm = SearchViewModel(dispatcher)

vm.onQueryChanged("p")
vm.onQueryChanged("pi")
vm.onQueryChanged("pix")
advanceTimeBy(299)
assertEquals(0, vm.searchCount) // still debouncing
advanceTimeBy(1)
runCurrent()
assertEquals(1, vm.searchCount) // fired once for "pix"
}
}

class MainDispatcherRule(
private val dispatcher: TestDispatcher = StandardTestDispatcher()
) : BeforeEachCallback, AfterEachCallback {
override fun beforeEach(ctx: ExtensionContext) = Dispatchers.setMain(dispatcher)
override fun afterEach(ctx: ExtensionContext) = Dispatchers.resetMain()
}

Three tools:

  • runTest { } — virtual time, auto-advances until all coroutines idle
  • advanceTimeBy(n) — fast-forward virtual time exactly n ms
  • runCurrent() — run coroutines that are due right now

Coroutine internals (at a glance)

Compiled Kotlin suspend functions are state machines. Every suspension point becomes a state; the continuation object holds the resume point and captured locals.

suspend fun fetch(id: String): User {
val dto = api.get(id) // suspend point 1
val entity = dao.upsert(dto) // suspend point 2
return entity.toDomain()
}

Becomes roughly:

fun fetch(id: String, continuation: Continuation<User>): Any? {
val sm = continuation as? FetchStateMachine ?: FetchStateMachine(continuation)
return when (sm.label) {
0 -> { sm.label = 1; api.get(id, sm) } // returns COROUTINE_SUSPENDED
1 -> { val dto = sm.result as Dto; sm.label = 2; dao.upsert(dto, sm) }
2 -> { val entity = sm.result as Entity; entity.toDomain() }
else -> error("impossible")
}
}

Implications: captured locals are fields on the continuation. Large captured state means heap pressure. Avoid capturing large objects in long- running coroutines; pass IDs and re-fetch instead.

Debugging in production

// Name coroutines for readable stack traces
viewModelScope.launch(CoroutineName("profile-load") + handler) { /* ... */ }

// Install debug probes in debug builds
if (BuildConfig.DEBUG) {
DebugProbes.install()
DebugProbes.dumpCoroutines() // dumps all running coroutines to logcat
}

On a device: adb shell dumpsys activity processes shows process state; Android Studio's Coroutine Inspector (profiler) visualizes live coroutines.

Key takeaways

Next

Continue to Kotlin Advanced Patterns for generics, DSLs, inline classes, and type-level programming.