Kotlin Coroutines
Overview
This skill provides expert guidance on Kotlin Coroutines, covering structured concurrency, scopes,
Dispatchers (including main-safe suspend and dispatcher injection), cancellation (including
withTimeout semantics), exception handling (CoroutineExceptionHandler, launch vs async),
Channels, Flow (cold vs hot, collectLatest, SharedFlow configuration, blocking in flow {}),
lifecycle-aware collection on Android, and testing (virtual time, setMain/resetMain). Use this
skill to help developers write safe, maintainable concurrent code aligned with Kotlin 1.9+/2.0+
conventions and official best practices.
Agent Behavior Contract (Follow These Rules)
- Identify the practice or error from the user's code or question (e.g. GlobalScope,
runBlocking in suspend, swallowing CancellationException) and open the corresponding
reference from the Triage table in
references/. - Apply the strict rules below in every response. Do not suggest or leave code that violates them.
- Respond in the required format: Analysis → Erroneous code → Optimized code → Explanation. If the user only asks a conceptual question (no code), skip erroneous/optimized snippets and focus on analysis and explanation.
- Do not recommend
GlobalScopein production. Use framework scopes (viewModelScope,lifecycleScope,rememberCoroutineScope), injected scopes, or local scopes (coroutineScope { },withContext { }). If an external scope is required, justify and document it. - Use
asynconly when a return value is needed; ifawait()is never called, uselaunch. Preserve structured concurrency: inside suspend functions usecoroutineScope { }+async/launch; do not launch in an external scope from suspend unless work must outlive the flow, and then document it. - Never use
runBlockinginside suspend functions or coroutine-based code. Avoid ending a suspend function withcoroutineScope { launch { } }as the last line when the intent is fire-and-forget —coroutineScopewaits for all children and blocks the caller; use an explicit external scope and document it if the work must truly run in the background beyond the caller's lifetime. - Use explicit Dispatchers:
Dispatchers.Defaultfor CPU-bound work,Dispatchers.Main/Main.immediatefor UI,withContext(Dispatchers.IO)for blocking I/O. Never perform blocking I/O on Default or Main. Do not useDispatchers.Unconfinedin production unless for a rare, documented case. Make suspend functions main-safe: move blocking work intowithContext(Dispatchers.IO)so callers on Main are never blocked. InjectCoroutineDispatcheras a constructor parameter (default to real dispatcher; replace withTestDispatcherin tests). - Never pass
Job()orSupervisorJob()directly to builders (e.g.launch(Job()) { }). UsesupervisorScope { }or a scope defined withSupervisorJob()for supervisor semantics. When running independent tasks withawaitAll, usesupervisorScopeinstead ofcoroutineScopeso one failure does not cancel sibling deferreds. - Cancellation handling (apply all):
- Never swallow
CancellationException; rethrow it in catch blocks. - Do not use
CancellationExceptionfor domain errors; use normal exceptions instead. - In long loops and repeating/polling work, add
yield(),ensureActive(), orwhile (isActive)withdelay(interval)so the coroutine responds to cancellation. - For suspend calls in
finally, usewithContext(NonCancellable) { }. - Do not reuse a scope after
scope.cancel(); usecoroutineContext.job.cancelChildren()to stop only children while keeping the scope alive. - Prefer
withTimeoutOrNulloverwithTimeoutto avoid unintentionally cancelling the parent scope. If usingwithTimeout, catchTimeoutCancellationExceptionexplicitly. Always ensure resources opened insidewithTimeoutare cleaned up infinally.
- Never swallow
- Exception handling:
- Uncaught exceptions in
launchpropagate toCoroutineExceptionHandler; inasync, the exception is stored in theDeferredand only thrown onawait(). Always callawait()onasyncblocks to avoid silently losing exceptions. - Use
CoroutineExceptionHandlerat scope level forlaunchuncaught exceptions.
- Uncaught exceptions in
- In tests use
kotlinx-coroutines-test:runTest, virtual time,advanceTimeBy,advanceUntilIdle, and injectTestDispatcher/StandardTestDispatcher; avoid realdelay()withrunBlocking. ReplaceDispatchers.MainusingDispatchers.setMain(TestDispatcher())in@BeforeandDispatchers.resetMain()in@After. - Prefer
produce { }for channels so they close when the coroutine ends. Do not shareconsumeEachacross multiple consumers; usefor (x in channel)per consumer. - Flow best practices:
- Keep
flow { }builder non-blocking; useflowOn(Dispatchers.IO)or suspend APIs. - Use
StateFlowfor shared UI state (replays last value); useSharedFlowfor events with explicitreplay,extraBufferCapacity, andonBufferOverflowconfiguration. - Use
collectLatestonly when cancelling in-progress work is intentional (e.g. search); usecollectwhen each item must be processed to completion. - On Android, collect flows with
repeatOnLifecycle(Lifecycle.State.STARTED)orflowWithLifecycleto stop collection when the UI goes to background.
- Keep
- When several practices apply (e.g. GlobalScope + wrong Dispatchers), use each relevant reference and combine the fixes in one optimized snippet.
Recommended Tools for Analysis
When analyzing Kotlin projects for coroutine issues:
- Project settings
- Use
Readonbuild.gradle.kts/build.gradlefor Kotlin version,kotlinx-coroutines-*dependencies, andkotlinx-coroutines-test. - Use
GrepforCoroutineScope,GlobalScope,runBlocking,Dispatchers,viewModelScope,lifecycleScopeto locate usage patterns.
- Use
- Scope and lifecycle
- Identify whether code runs on Android (viewModelScope, lifecycleScope), KMP, or plain JVM to recommend the right scope.
Triage-First Playbook (Topic / Error → Reference)
Each references/ref-*.md file is self-contained (Bad Practice, Recommended, Why, Quick fix). Open the linked file directly — no dependency on repo docs when the skill is installed standalone.
| Topic / Error / Question | Reference file |
|---|---|
| GlobalScope, scope lifetime, "where should I launch?" | ref-1-1-global-scope.md |
| async without await, fire-and-forget with async | ref-1-2-async-without-await.md |
| Breaking structured concurrency, launching in external scope from suspend | ref-1-3-breaking-structured-concurrency.md |
| awaitAll exception propagation, coroutineScope vs supervisorScope for parallel tasks | ref-1-4-awaitall-exception-propagation.md |
| coroutineScope { launch { } } as last line, "wait vs don't wait" | ref-2-1-launch-last-line-coroutine-scope.md |
| runBlocking inside suspend, blocking in coroutines | ref-2-2-runblocking-in-suspend.md |
| Blocking I/O on Default/Main, wrong Dispatchers for I/O | ref-3-1-blocking-wrong-dispatchers.md |
| Main-safe suspend functions, suspend blocking caller thread, ANR | ref-3-2-main-safe-suspend.md |
| Dispatchers.Unconfined in production | ref-3-2-dispatchers-unconfined.md |
| Job() / SupervisorJob() passed to launch/async/withContext | ref-3-3-job-context-builders.md |
| Injecting Dispatchers, hardcoded dispatcher, flaky tests, testability | ref-3-5-inject-dispatchers.md |
| Cancellation in loops, long loops not responding to cancel | ref-4-1-cancellation-intensive-loops.md |
| Periodic / repeating work, polling, zombie coroutine, infinite loop without isActive | ref-4-2-periodic-repeating-work.md |
| Swallowing CancellationException, catch Exception and cancel | ref-4-2-swallowing-cancellation-exception.md |
| Suspend in finally, cleanup that needs to suspend | ref-4-3-suspend-cleanup-noncancellable.md |
| Reusing scope after cancel(), cancelChildren vs cancel | ref-4-4-reusing-cancelled-scope.md |
| withTimeout scope cancellation, TimeoutCancellationException, withTimeoutOrNull | ref-4-6-withtimeout-scope-cancellation.md |
| withTimeout resource cleanup, resource leak on timeout, finally, NonCancellable | ref-4-7-withtimeout-resource-cleanup.md |
| SupervisorJob() in a single builder | ref-5-1-supervisor-job-single-builder.md |
| CancellationException for domain errors (e.g. UserNotFound) | ref-5-2-cancellation-exception-domain-errors.md |
| CoroutineExceptionHandler vs async, exceptions in async stored in Deferred until await() | ref-5-3-exception-handler-async.md |
| Slow tests, real delay() in tests | ref-6-1-slow-tests-real-delays.md |
| Uncontrolled fire-and-forget in tests, can't wait in tests | ref-6-2-uncontrolled-fire-and-forget-tests.md |
| Dispatchers.Main in tests, setMain/resetMain, CI flaky tests | ref-6-3-setmain-resetmain.md |
| Channel not closed, manual Channel without close() | ref-7-1-channel-close.md |
| consumeEach with multiple consumers | ref-7-2-consume-each-multiple-consumers.md |
| Architecture, layers (Data/Domain/Presentation), suspend vs callbacks | ref-8-architecture-patterns.md |
| Lifecycle-aware Flow collection (Android), repeatOnLifecycle, flowWithLifecycle | ref-8-2-lifecycle-aware-flow.md |
| Blocking in flow { }, Thread.sleep in flow, flowOn | ref-9-1-flow-blocking-call.md |
| Cold vs hot flows, StateFlow, SharedFlow, shareIn, stateIn, collect | ref-9-2-cold-vs-hot-flows.md |
| collectLatest semantics, cancels previous block, search vs complete work | ref-9-3-collect-latest.md |
| SharedFlow configuration, replay, extraBufferCapacity, onBufferOverflow, backpressure | ref-9-4-shared-flow-configuration.md |
| suspendCoroutine without cancellation, callback bridge | ref-101-interop-suspend-cancellable.md |
| callbackFlow without awaitClose, listener leak | ref-102-interop-callbackflow-awaitclose.md |
| channelFlow vs callbackFlow, wrong flow builder | ref-103-interop-channelflow-callbackflow.md |
| Future.get in coroutine, CompletableFuture blocking | ref-104-interop-future-get.md |
| MutableStateFlow exposed publicly, UDF violation | ref-95-flow-mutable-exposed.md |
| Flow chain missing catch, launchIn/collect without catch | ref-96-flow-missing-catch.md |
| runBlocking in tests, runTest virtual time | ref-64-test-runtest.md |
| Dispatchers.IO in commonMain, KMP crash | ref-111-kmp-dispatchers-io.md |
| runBlocking in commonMain, KMP deadlock | ref-112-kmp-runblocking.md |
| collectAsState without lifecycle, Compose background collection | ref-83-compose-collect-lifecycle.md |
| rememberCoroutineScope for init, LaunchedEffect | ref-84-compose-remember-scope-init.md |
| Side effect in composable body, recomposition | ref-85-compose-side-effect.md |
| Hardcoded Dispatchers in class, testability | ref-65-test-hardcoded-dispatcher.md |
| advanceUntilIdle missing in runTest, flaky test | ref-66-test-not-completed.md |
| flatMapLatest vs merge vs concat, Flow operator choice | ref-910-flow-flatmap-choice.md |
| SharedFlow one-shot events, Channel for navigation | ref-911-flow-oneshot-events.md |
| synchronized in coroutine, Mutex | ref-121-concur-synchronized.md |
| Sequential async await, wasted Deferred | ref-15-concur-sequential-async.md |
| stateIn Eagerly on ViewModel, WhileSubscribed | ref-97-flow-statein-eagerly.md |
| launchIn GlobalScope, unstructured Flow collection | ref-98-flow-launchin-unstructured.md |
| Side effect in map operator, onEach for effects | ref-99-flow-sideeffect-map.md |
| MDC not propagated, MDCContext | ref-37-backend-mdc.md |
| Blocking JDBC in backend coroutine, Dispatchers.IO | ref-131-backend-blocking.md |
| Missing CoroutineName, debugging | ref-141-debug-coroutine-name.md |
| Redundant nested withContext, same dispatcher twice | ref-36-concur-redundant-withcontext.md |
| MainScope without cancel, KMP presenter leak | ref-113-kmp-mainscope-without-cancel.md |
| Shared mutable state in launch, race on ArrayList | ref-122-concur-shared-mutable-state.md |
| withTimeout cancels parent scope, use withTimeoutOrNull | ref-4-6-withtimeout-scope-cancellation.md |
| Job in builder, SupervisorJob as argument | ref-3-3-job-context-builders.md |
| External scope launch from suspend, structured concurrency break | ref-1-3-breaking-structured-concurrency.md |
| Inline CoroutineScope, scope(CoroutineScope(...)) | ref-1-3-breaking-structured-concurrency.md |
| Scope reuse after cancel, launch after scope.cancel() | ref-4-4-reusing-cancelled-scope.md |
| ViewModelScope leak, collect without lifecycle | ref-8-2-lifecycle-aware-flow.md |
Core Patterns Reference
When to Use Each Coroutine Tool
launch – Fire-and-forget or UI-triggered work that does not return a value
// Use for: Work that does not need a result; lifecycle-bound to scope
viewModelScope.launch {
updateUI(loadData())
}
async / await – Parallel work when you need a return value
// Use for: Deferred result; always await (or awaitAll) to preserve structure
coroutineScope {
val a = async { fetchA() }
val b = async { fetchB() }
combine(a.await(), b.await())
}
coroutineScope – Structured child work inside a suspend function
// Use for: Subtasks that must complete or cancel with the current scope
suspend fun loadAll() = coroutineScope {
val { loadOne() }
val two = async { loadTwo() }
Pair(one.await(), two.await())
}
withContext – Switch dispatcher or run cleanup (e.g. NonCancellable)
// Use for: Blocking I/O off Main/Default; cleanup in finally
withContext(Dispatchers.IO) { readFile(path) }
withContext(NonCancellable) { db.close() }
supervisorScope – Children do not cancel each other on failure
// Use for: Independent child jobs (e.g. multiple UI updates)
supervisorScope {
launch { updateA() }
launch { updateB() }
}
produce – Channel that closes when the coroutine ends
// Use for: Single producer; automatic close when scope completes
fun CoroutineScope.flowFromApi() = produce {
for (item in api.stream()) send(item)
}
Common Scenarios
Scenario: Single network request with UI update
viewModelScope.launch {
val data = withContext(Dispatchers.IO) { repo.fetch() }
updateUI(data)
}
Scenario: Multiple parallel requests
coroutineScope {
val users = async { repo.getUsers() }
val posts = async { repo.getPosts() }
show(users.await(), posts.await())
}
Scenario: Cancellation-friendly loop
for (i in list) {
yield() // or ensureActive()
process(i)
}
Best Practices Summary
- Prefer structured concurrency – Use
coroutineScope+async/launchinside suspend; avoid launching in external scope from suspend unless documented. For independent tasks, usesupervisorScope+awaitAllso one failure does not cancel siblings. - Use the right scope – Framework scopes (viewModelScope, lifecycleScope) or injected scope; never GlobalScope in production.
- Use async only when you need a result – Otherwise use
launch. Always callawait()on everyDeferred; exceptions are only thrown atawait(), not caught byCoroutineExceptionHandler. - Use explicit, injected Dispatchers – IO for blocking I/O, Default for CPU, Main for UI; never
block on Default/Main. Make suspend functions main-safe (
withContext(IO)internally). Inject dispatchers for testability. - Respect cancellation – Rethrow CancellationException; use
while(isActive)+delayfor repeating work; useyield/ensureActivein long loops; usewithContext(NonCancellable)for suspend cleanup infinally. PreferwithTimeoutOrNull; ensure resources are cleaned up on timeout. - Do not misuse CancellationException – Use normal exceptions for domain errors.
- Test with virtual time –
runTest,TestDispatcher,advanceTimeBy; avoid realdelay()in tests. ReplaceDispatchers.MainwithsetMain/resetMainfor reliable CI tests. - Channels – Prefer
produce; if using Channel manually, document whenclose()is called; one consumer per channel withfor (x in channel). - Flow – Keep
flow { }non-blocking (useflowOn); useStateFlowfor state,SharedFlowfor events with explicit buffer/overflow config; usecollectLatestonly when cancelling in-progress work is intentional. - Android lifecycle – Collect flows with
repeatOnLifecycle(STARTED)orflowWithLifecycleso collection stops when UI is in background.
Output Format (Required for Code Review / Refactor)
Structure every code-review or refactor response as:
- Problem Analysis – Short description of what is wrong (e.g. scope lifetime, dispatcher, exception handling) and the risk (leaks, ANRs, flaky tests).
- Erroneous Code – The original or problematic code snippet (clearly labeled).
- Optimized Code – Refactored code that follows the guidelines (structured concurrency, correct scopes, Dispatchers, exception/cancellation handling).
- Technical Explanation – Why the optimized version is safer or more correct: lifecycle, cancellation propagation, thread usage, testability.
For conceptual-only questions, skip erroneous/optimized snippets and keep analysis and explanation.
Verification Checklist (When You Change Coroutine Code)
- Confirm which scope is in use (framework, injected, or local) and that it matches lifecycle expectations.
- After refactors: run tests, especially those that use coroutines or virtual time (see ref-6-*).
- If touching cancellation or cleanup: ensure CancellationException is rethrown and suspend cleanup uses NonCancellable where needed.
- If touching Dispatchers: ensure no blocking I/O on Default or Main.
Source: santimattius/structured-coroutines — distributed by TomeVault.