Write data-safe asynchronous Kotlin with coroutines, Flow, and structured concurrency on Android. Use when fixing coroutine cancellation or leak bugs, choosing dispatchers (Default/IO/Main), designing suspend functions, modeling streams with Flow/StateFlow/SharedFlow, scoping work to viewModelScope/lifecycleScope, handling exceptions with CoroutineExceptionHandler/supervisorScope, or converting callback/RxJava APIs to coroutines. Covers Kotlin 2.x, kotlinx.coroutines structured concurrency di...
Scanned 9/2/2026
Install to Claude Code
npx -y skills add eric-sabe/engsys --skill kotlin-coroutines --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Kotlin Coroutines?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/eric-sabe-kotlin-coroutines)More formats (shields.io, HTML) on the badges page.
---
name: kotlin-coroutines
description: "Write data-safe asynchronous Kotlin with coroutines, Flow, and structured concurrency on Android. Use when fixing coroutine cancellation or leak bugs, choosing dispatchers (Default/IO/Main), designing suspend functions, modeling streams with Flow/StateFlow/SharedFlow, scoping work to viewModelScope/lifecycleScope, handling exceptions with CoroutineExceptionHandler/supervisorScope, or converting callback/RxJava APIs to coroutines. Covers Kotlin 2.x, kotlinx.coroutines structured concurrency discipline, and the Android main-safety contract."
---
# Kotlin Coroutines
Review, fix, and write concurrent Kotlin targeting Kotlin 2.1+ and
kotlinx.coroutines 1.9+. Apply structured concurrency, correct dispatcher
choice, and cooperative cancellation with minimal behavior changes. This is the
Kotlin equivalent of Swift's structured-concurrency discipline: scopes are the
unit of lifetime, cancellation is cooperative, and main-safety is non-negotiable.
## Contents
- [Triage Workflow](#triage-workflow)
- [Structured Concurrency](#structured-concurrency)
- [Coroutine Scopes](#coroutine-scopes)
- [Dispatchers and Main-Safety](#dispatchers-and-main-safety)
- [Cancellation](#cancellation)
- [Exception Handling](#exception-handling)
- [Flow](#flow)
- [StateFlow and SharedFlow](#stateflow-and-sharedflow)
- [Bridging Callback APIs](#bridging-callback-apis)
- [Common Mistakes](#common-mistakes)
- [Review Checklist](#review-checklist)
## Triage Workflow
When diagnosing a coroutine issue, follow this sequence:
### Step 1: Capture context
- Identify the scope the coroutine runs in (`viewModelScope`, `lifecycleScope`,
a custom `CoroutineScope`, or an unscoped `GlobalScope` — a red flag).
- Identify the dispatcher in effect and whether the work is CPU-bound,
IO-bound, or UI-bound.
- Determine whether the work must survive configuration changes / navigation, or
should be cancelled with the screen.
- Copy any stack trace; note whether it is a `CancellationException` (normal) or
a real failure.
### Step 2: Apply the smallest safe fix
| Situation | Recommended fix |
|---|---|
| Work tied to a screen | Launch in `viewModelScope`; never `GlobalScope`. |
| Blocking IO (disk, network, DB) | `withContext(Dispatchers.IO) { ... }`. |
| Heavy CPU work (parse, sort, image) | `withContext(Dispatchers.Default) { ... }`. |
| UI / state update | Stay on `Dispatchers.Main` (the default for `viewModelScope`). |
| One child failure must not kill siblings | Use `supervisorScope` / `SupervisorJob`. |
| Long loop never cancels | Add `ensureActive()` / `yield()` or use cancellable APIs. |
| Callback API | Wrap with `suspendCancellableCoroutine` or `callbackFlow`. |
### Step 3: Verify
- Confirm cancellation propagates (the work stops when the scope is cancelled).
- Confirm no work runs on the main thread that blocks it.
- Confirm exceptions surface (not silently swallowed) and `CancellationException`
is never caught-and-ignored.
## Structured Concurrency
Every coroutine has a parent. A parent does not complete until all children
complete; cancelling a parent cancels all children. This is the core safety
guarantee — never break out of it.
- Prefer `coroutineScope { }` to launch concurrent children that all must finish
before the function returns.
- Use `async`/`await` for concurrent work that produces values.
- Use `launch` for fire-and-forget work *within a scope* (not detached).
```kotlin
suspend fun loadDashboard(): Dashboard = coroutineScope {
val profile = async { repo.profile() } // runs concurrently
val feed = async { repo.feed() } // runs concurrently
Dashboard(profile.await(), feed.await()) // both joined here
}
```
If either child throws, the scope cancels the sibling and rethrows — no leaks.
**Never** use `GlobalScope.launch`. It has no parent, no lifecycle, no
cancellation. It is the coroutine equivalent of a detached, unowned thread.
## Coroutine Scopes
| Scope | Lifecycle | Use for |
|---|---|---|
| `viewModelScope` | Cleared when the `ViewModel` clears | All ViewModel async work |
| `lifecycleScope` | Tied to a `Lifecycle` (Activity/Fragment) | UI-layer work bound to a screen |
| `rememberCoroutineScope()` | Tied to a composable's lifetime | Launching from Compose event callbacks |
| custom `CoroutineScope` | You manage `cancel()` | Long-lived components (cancel explicitly) |
For a custom scope, always pair a `SupervisorJob` with a dispatcher and cancel it
when the owner is destroyed:
```kotlin
class Connection {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun close() = scope.cancel() // cancels all children — no leaks
}
```
In Compose, collect with `collectAsStateWithLifecycle()` (not `collectAsState()`)
so collection stops when the app is in the background.
## Dispatchers and Main-Safety
A suspend function should be **main-safe**: callable from the main thread without
blocking it. Push the thread switch *down* into the function, not up to callers.
- `Dispatchers.Main` — UI updates. The default for `viewModelScope`.
- `Dispatchers.Default` — CPU-bound work (parsing, sorting, image processing).
Backed by a pool sized to CPU cores.
- `Dispatchers.IO` — blocking IO (disk, network sockets, JDBC, file). Backed by
a large elastic pool.
- `Dispatchers.Main.immediate` — runs inline if already on the main thread,
avoiding an unnecessary re-dispatch (use for state emission).
```kotlin
// Main-safe: caller can invoke from the main thread.
suspend fun parseLargeJson(raw: String): Model = withContext(Dispatchers.Default) {
json.decodeFromString(raw)
}
```
Rules:
1. Never block the main thread (`Thread.sleep`, blocking network, `runBlocking`).
2. Use `withContext`, not `launch`, to switch dispatchers for a value-returning
step — `withContext` returns to the original dispatcher when done.
3. Don't hardcode dispatchers in the call site you want to test; inject a
dispatcher (or a `CoroutineDispatcher` provider) so tests can substitute
`StandardTestDispatcher`.
## Cancellation
Cancellation is cooperative. A coroutine only stops at a suspension point or when
it checks cooperatively.
```kotlin
suspend fun process(items: List<Item>) {
for (item in items) {
ensureActive() // throws CancellationException if cancelled
heavyWork(item) // CPU loops won't cancel on their own
}
}
```
- Suspending functions from kotlinx.coroutines (`delay`, `withContext`, Flow
operators) are cancellable automatically.
- Tight CPU loops are **not** — insert `ensureActive()` or `yield()`.
- `CancellationException` is special: it signals normal cancellation. Never catch
it and swallow it. If you catch `Exception` broadly, rethrow it:
```kotlin
try {
risky()
} catch (e: CancellationException) {
throw e // must rethrow — never swallow
} catch (e: Exception) {
handle(e)
}
```
- For cleanup that must run even after cancellation, use
`withContext(NonCancellable) { ... }` inside a `finally`.
## Exception Handling
- In `coroutineScope` / `async`, exceptions propagate to the awaiting caller —
handle with `try/catch` around `await()`.
- In `launch`, an uncaught exception propagates up and cancels the scope. Install
a `CoroutineExceptionHandler` on the **root** scope for last-resort handling.
- `supervisorScope` / `SupervisorJob` isolate child failures so one failing child
does not cancel its siblings:
```kotlin
supervisorScope {
launch { mightFail() } // failure here does NOT cancel the sibling
launch { mustSucceed() }
}
```
- A `CoroutineExceptionHandler` only works on root `launch` coroutines, never on
`async` (whose exception surfaces at `await`).
## Flow
`Flow<T>` is a cold asynchronous stream — it does nothing until collected, and
re-runs its producer for each collector. It is the Kotlin analogue of an
`AsyncSequence`.
```kotlin
fun searchResults(query: String): Flow<List<Result>> = flow {
emit(emptyList()) // initial
emit(repo.search(query)) // suspends, emits result
}
```
Key operators:
- Transform: `map`, `filter`, `transform`, `flatMapLatest` (cancel-previous —
ideal for search-as-you-type), `flatMapConcat`, `flatMapMerge`.
- Combine: `combine`, `zip`, `merge`.
- Lifecycle: `onStart`, `onEach`, `onCompletion`, `catch` (upstream errors only),
`retry`.
- Backpressure/threading: `flowOn(Dispatchers.IO)` changes the **upstream**
dispatcher; `buffer`, `conflate`, `debounce`, `sample`.
```kotlin
fun results(query: Flow<String>): Flow<UiState> = query
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { q -> repo.search(q) } // cancels stale searches
.map(UiState::Loaded)
.catch { emit(UiState.Error(it)) }
.flowOn(Dispatchers.Default)
```
Rules:
- Use `flowOn` to set the producer dispatcher; never call `withContext` inside a
`flow { }` builder (it breaks context preservation — the builder enforces this).
- Put `catch` *downstream* of the operators whose errors you want to handle;
collector-side errors are not caught by `catch`.
## StateFlow and SharedFlow
These are **hot** flows for UI state and events.
- `StateFlow<T>` — holds a single current value, conflates, always has a value.
The standard holder for screen UI state. Emit via `MutableStateFlow`.
- `SharedFlow<T>` — broadcasts events to multiple collectors; configurable
replay. Use for one-shot events (navigation, snackbars) with `replay = 0`.
```kotlin
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
private val _state = MutableStateFlow<FeedUiState>(FeedUiState.Loading)
val state: StateFlow<FeedUiState> = _state.asStateFlow()
init {
viewModelScope.launch {
_state.value = try {
FeedUiState.Loaded(repo.feed())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
FeedUiState.Error(e.message)
}
}
}
}
```
Convert a cold flow to a lifecycle-aware hot `StateFlow` with `stateIn`:
```kotlin
val items: StateFlow<List<Item>> = repo.observeItems()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // stop 5s after last collector
initialValue = emptyList()
)
```
`WhileSubscribed(5_000)` keeps the upstream alive briefly across configuration
changes while still stopping when nothing observes — the standard choice.
For one-shot events prefer a `Channel` consumed as a flow, or a `SharedFlow` with
`replay = 0`; do **not** model events as `StateFlow` (state is re-delivered on
recomposition and replays stale events).
## Bridging Callback APIs
Single value:
```kotlin
suspend fun awaitLocation(): Location = suspendCancellableCoroutine { cont ->
val cb = LocationCallback { loc -> cont.resume(loc) }
locationManager.request(cb)
cont.invokeOnCancellation { locationManager.remove(cb) } // clean up
}
```
Stream of values:
```kotlin
fun locationUpdates(): Flow<Location> = callbackFlow {
val cb = LocationCallback { loc -> trySend(loc) }
locationManager.request(cb)
awaitClose { locationManager.remove(cb) } // required — cleans up on cancel
}
```
## Common Mistakes
1. **`GlobalScope.launch`** — no lifecycle, leaks. Use a real scope.
2. **`runBlocking` in production code** — blocks the thread; it is for `main()`
and tests only.
3. **Swallowing `CancellationException`** — breaks structured cancellation;
always rethrow it.
4. **Blocking the main thread** — `Thread.sleep`, blocking IO, or heavy CPU on
`Dispatchers.Main`.
5. **`withContext` inside a `flow { }` builder** — use `flowOn` instead.
6. **Collecting flows with `collectAsState()` in Compose** — use
`collectAsStateWithLifecycle()` so collection pauses in the background.
7. **Hardcoded dispatchers** — inject them so tests can swap in test
dispatchers.
8. **Modeling one-shot events as `StateFlow`** — they replay stale events; use a
`Channel`/`SharedFlow(replay = 0)`.
9. **Launching independent work that should be concurrent sequentially** — use
`async`/`await` in a `coroutineScope`.
10. **Catching exceptions on `async` with a `CoroutineExceptionHandler`** — it
does not fire for `async`; catch at `await`.
11. **Forgetting `awaitClose` in `callbackFlow`** — leaks the callback.
## Review Checklist
- [ ] No `GlobalScope`; work runs in `viewModelScope`/`lifecycleScope`/owned scope
- [ ] No `runBlocking` outside `main`/tests
- [ ] Suspend functions are main-safe (`withContext` pushes the switch down)
- [ ] CPU loops check cancellation (`ensureActive`/`yield`)
- [ ] `CancellationException` is rethrown, never swallowed
- [ ] Child-failure isolation uses `supervisorScope`/`SupervisorJob` where needed
- [ ] `flowOn` sets upstream dispatcher; no `withContext` inside `flow {}`
- [ ] UI state exposed as immutable `StateFlow` (backed by `MutableStateFlow`)
- [ ] `stateIn` uses `WhileSubscribed(5_000)` for screen state
- [ ] One-shot events are not modeled as `StateFlow`
- [ ] `callbackFlow`/`suspendCancellableCoroutine` clean up on cancellation
- [ ] Dispatchers injected for testability
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!