| name | kotlin-coroutines |
| description | Writes correct, performant Kotlin Coroutines and Flow code following structured concurrency. Use when user asks to "use coroutines", "implement async", "how does StateFlow work", "collect a Flow", or "fix a coroutine issue". |
Kotlin Coroutines
Overview
Best practices for Kotlin Coroutines — covering structured concurrency, Flow operators, StateFlow, SharedFlow, error handling, testing, and common pitfalls.
Structured Concurrency
Always Use a Scoped Coroutine
GlobalScope.launch { fetchData() }
class MyViewModel : ViewModel() {
fun load() { viewModelScope.launch { fetchData() } }
}
class MyFragment : Fragment() {
fun load() { viewLifecycleOwner.lifecycleScope.launch { fetchData() } }
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun destroy() { scope.cancel() }
Dispatchers — Inject, Never Hardcode
suspend fun fetchFromDb() = withContext(Dispatchers.IO) { dao.getAll() }
class MyRepository(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun fetchFromDb() = withContext(ioDispatcher) { dao.getAll() }
}
val repo = MyRepository(ioDispatcher = UnconfinedTestDispatcher())
SupervisorJob for Independent Children
val scope = CoroutineScope(Job())
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
async / await for Parallel Work
val userResult = fetchUser()
val settingsResult = fetchSettings()
coroutineScope {
val user = async { fetchUser() }
val settings = async { fetchSettings() }
combine(user.await(), settings.await())
}
Flow
Cold vs Hot
| Type | When emits | Subscribers |
|---|
Flow (cold) | Per collector | Each gets fresh stream |
StateFlow (hot) | Always alive | Latest value replayed |
SharedFlow (hot) | Always alive | Configurable replay |
Flow Operators — Common Patterns
flow.map { transform(it) }
flow.flatMapLatest { id -> fetchDetails(id) }
flow.flatMapMerge { id -> fetchDetails(id) }
flow.filter { it.isValid }
flow.filterNotNull()
flow.distinctUntilChanged()
flow.distinctUntilChangedBy { it.id }
combine(flowA, flowB) { a, b -> Pair(a, b) }
zip(flowA, flowB) { a, b -> Pair(a, b) }
flow.catch { e -> emit(defaultValue) }
flow.retry(3) { e -> e is IOException }
flow.retryWhen { e, attempt -> attempt < 3 && e is IOException }
flow.debounce(300)
flow.throttleFirst(500)
flow.sample(1_000)
flow.first()
flow.firstOrNull()
flow.toList()
flow.single()
flatMapLatest vs flatMapMerge vs flatMapConcat
| Operator | Behavior | Use Case |
|---|
flatMapLatest | Cancels previous inner flow on new emission | Search-as-you-type |
flatMapMerge | Concurrent, emissions interleaved | Parallel independent fetches |
flatMapConcat | Sequential, waits for each inner flow | Order-dependent operations |
StateFlow
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()
_state.update { current -> current.copy(isLoading = true) }
val isLoading: StateFlow<Boolean> = _state
.map { it.isLoading }
.stateIn(scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = false)
stateIn — SharingStarted Options
| Strategy | Behavior | Use |
|---|
Eagerly | Starts immediately, never stops | Always-active state |
Lazily | Starts on first subscriber, never stops | ViewModel state |
WhileSubscribed(5_000) | Starts on first subscriber, stops 5s after last unsubscribes | Memory-efficient ViewModel state |
Best practice for ViewModels: WhileSubscribed(5_000) — survives brief background/rotation gaps.
SharedFlow
private val _events = MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
fun emitEvent(event: UiEvent) {
viewModelScope.launch { _events.emit(event) }
}
Use Channel instead when you need exactly-once delivery with backpressure:
private val _channel = Channel<UiEvent>(Channel.BUFFERED)
val events: Flow<UiEvent> = _channel.receiveAsFlow()
Error Handling
Use Result<T> for Expected Failures
suspend fun fetchUser(id: String): Result<User> = runCatching {
api.getUser(id)
}
fetchUser("123")
.onSuccess { user -> updateUi(user) }
.onFailure { error -> showError(error.message) }
try/catch — What to Catch
try {
api.call()
} catch (e: IOException) {
} catch (e: HttpException) {
}
try {
delay(1000)
} catch (e: Exception) {
log(e)
}
try {
delay(1000)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(e)
}
Flow Error Handling
val safeFlow = upstream
.catch { e -> emit(Result.failure(e)) }
viewModelScope.launch {
safeFlow.collect { result ->
result.onSuccess { }.onFailure { }
}
}
Lifecycle-Safe Collection in UI
lifecycleScope.launch {
viewModel.state.collect { render(it) }
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { render(it) }
}
}
val state by viewModel.state.collectAsStateWithLifecycle()
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|
GlobalScope | Memory leaks, crashes after process death | Use structured scopes |
| Hardcoded dispatcher | Tests run on real threads, timeouts | Inject dispatchers |
Catching CancellationException | Coroutine won't cancel | Re-throw it |
runBlocking in production | ANR / UI freeze | Use launch/async |
Missing supervisorScope for parallel jobs | One failure cancels all | Use supervisorScope { } |
collect without lifecycle guard | Collects in background | repeatOnLifecycle |
Multiple StateFlow in ViewModel | State fragmentation, inconsistent UI | Single UiState data class |
Hot flow shared with replay=0 missing event | Events lost before subscriber attaches | Use replay=1 or Channel |
launchWhenStarted / launchWhenResumed | Deprecated — does not cancel the coroutine, only suspends it | Use repeatOnLifecycle |
Cooperative Cancellation
Coroutines are only cancelled at suspension points. A tight loop without suspension will never cancel.
suspend fun processLargeList(items: List<Item>) {
items.forEach { item ->
processItem(item)
}
}
suspend fun processLargeList(items: List<Item>) {
items.forEach { item ->
ensureActive()
processItem(item)
}
}
suspend fun processLargeList(items: List<Item>) {
items.forEach { item ->
yield()
processItem(item)
}
}
Callback Conversion
Convert callback-based APIs to Flow using callbackFlow. Always clean up in awaitClose.
fun locationUpdates(): Flow<Location> = callbackFlow {
val listener = LocationListener { location ->
trySend(location)
}
locationManager.requestLocationUpdates(criteria, listener, Looper.getMainLooper())
awaitClose {
locationManager.removeUpdates(listener)
}
}
locationUpdates()
.onEach { location -> updateMap(location) }
.launchIn(viewModelScope)
Use trySend (non-suspending) inside the listener. Never use send directly in a callback.
Testing Coroutines
@Test
fun `SHOULD emit loading then content`() = runTest {
coEvery { repository.fetch() } returns Result.success(listOf(item))
viewModel.load()
advanceUntilIdle()
assertEquals(listOf(item), viewModel.state.value.items)
assertFalse(viewModel.state.value.isLoading)
}
@Test
fun `SHOULD emit events in order`() = runTest {
viewModel.events.test {
viewModel.triggerAction()
assertEquals(UiEvent.ShowSuccess, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
References