Kotlin Coroutines and Flow
Use these patterns for async work and data streams in Android and Kotlin Multiplatform projects.
Use This Skill When
- Writing async Kotlin code
- Using
Flow,StateFlow, orSharedFlow - Loading data at the same time
- Adding debounce or retry logic
- Managing scope and cancel rules
- Testing coroutines and flows
Core Rules
- Use a scope tied to a clear owner.
- Do not use
GlobalScope. - Let cancel errors pass through.
- Keep state values read-only outside their owner.
- Use immutable state and list copies.
- Pass dispatchers into code that needs easy tests.
- Collect UI flows only while the UI is active.
Structured Concurrency
A parent scope owns its child jobs:
Application
โโโ viewModelScope
โโโ coroutineScope
โโโ async
โโโ asyncUse a scope with a known life:
// Bad: no clear owner
GlobalScope.launch {
fetchData()
}
// Good: stops when the ViewModel is cleared
viewModelScope.launch {
fetchData()
}
// Good: stops when this Compose effect leaves the UI
LaunchedEffect(key) {
fetchData()
}Do not create a new CoroutineScope unless the code owns it and can cancel it.
Run Work at the Same Time
Use coroutineScope with async when all results are needed:
suspend fun loadDashboard(): Dashboard = coroutineScope {
val items = async { itemRepository.getRecent() }
val stats = async { statsRepository.getToday() }
val profile = async { userRepository.getCurrent() }
Dashboard(
items = items.await(),
stats = stats.await(),
profile = profile.await()
)
}If one child fails, the other children are canceled. The error is sent to the caller.
Do not use async for work whose result is never read. Use launch for that work.
Keep Other Children Running
Use supervisorScope when one child may fail without stopping its siblings:
suspend fun syncAll() = supervisorScope {
val items = async { runCatching { syncItems() } }
val stats = async { runCatching { syncStats() } }
val settings = async { runCatching { syncSettings() } }
listOf(
items.await(),
stats.await(),
settings.await()
)
}Handle each error. A failed launch can still reach the app error handler if no code catches it.
Flow Patterns
Cold Flow
A cold flow starts again for each collector.
If a data source already returns a flow, map it directly:
fun observeItems(): Flow<List<Item>> =
itemDao.observeAll()
.map { rows -> rows.map { it.toDomain() } }Use flow {} when you need to turn suspend work into a flow:
fun loadItem(id: String): Flow<Item> = flow {
emit(api.getItem(id))
}Do not wrap an existing flow in flow { collect { emit(it) } } unless extra control is needed.
StateFlow for UI State
Keep mutable state private:
class DashboardViewModel(
observeProgress: ObserveUserProgressUseCase
) : ViewModel() {
val progress: StateFlow<UserProgress> = observeProgress()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UserProgress.EMPTY
)
}WhileSubscribed(5_000) keeps the source active for five seconds after the last collector leaves. This can avoid a restart during a screen change.
Pick the start rule on purpose:
- Use
WhileSubscribedfor UI state. - Use
Lazilywhen work should start on the first collector. - Use
Eagerlyonly when work must start at once.
The first value is initialValue. Make sure it is safe to show.
Join More Than One Flow
Use combine when any input change should make new state:
val uiState: StateFlow<HomeState> = combine(
itemRepository.observeItems(),
settingsRepository.observeTheme(),
userRepository.observeProfile()
) { items, theme, profile ->
HomeState(
items = items,
theme = theme,
profile = profile
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = HomeState()
)combine waits until each input has sent at least one value.
Search with Debounce
Use flatMapLatest when a new query should stop the old search:
searchQuery
.debounce(300)
.map(String::trim)
.distinctUntilChanged()
.flatMapLatest { query ->
if (query.isBlank()) {
flowOf(emptyList())
} else {
repository.search(query)
}
}
.catch { error ->
if (error is CancellationException) throw error
emit(emptyList())
}
.collect { results ->
_state.update { it.copy(results = results) }
}The catch operator handles errors from code above it. It does not catch errors thrown by the collector below it.
Retry with a Limit
Retry only errors that may pass after a short wait:
fun fetchWithRetry(): Flow<Data> =
flow {
emit(api.fetch())
}.retryWhen { cause, attempt ->
if (cause is IOException && attempt < 3) {
val waitMs = 1_000L * (1L shl attempt.toInt())
delay(waitMs)
true
} else {
false
}
}This makes up to four calls: the first call and three retries.
Do not retry login errors, bad input, or other errors that need user action.
SharedFlow for Effects
Use a SharedFlow for effects that may be seen by active collectors:
class ItemListViewModel : ViewModel() {
private val _effects = MutableSharedFlow<Effect>(
extraBufferCapacity = 1
)
val effects: SharedFlow<Effect> = _effects.asSharedFlow()
sealed interface Effect {
data class ShowSnackbar(val message: String) : Effect
data class NavigateTo(val route: String) : Effect
}
fun deleteItem(id: String) {
viewModelScope.launch {
repository.delete(id)
_effects.emit(Effect.ShowSnackbar("Item deleted"))
}
}
}Collect it in Compose:
LaunchedEffect(viewModel) {
viewModel.effects.collect { effect ->
when (effect) {
is Effect.ShowSnackbar ->
snackbarHostState.showSnackbar(effect.message)
is Effect.NavigateTo ->
navController.navigate(effect.route)
}
}
}A SharedFlow with replay = 0 does not save an effect for a later collector. If an effect must not be lost, store it in UI state or use a Channel with one clear receiver.
Do not use StateFlow for one-time events. A new collector may see the old event again.
Dispatchers
Use a dispatcher that fits the work:
// Heavy CPU work
withContext(Dispatchers.Default) {
parseJson(largePayload)
}
// Blocking file, network, or database work on Android or JVM
withContext(Dispatchers.IO) {
database.query()
}
// UI work
withContext(Dispatchers.Main) {
updateUi()
}Many modern database and network tools already move blocking work off the main thread. Check the tool before adding withContext.
For KMP, do not assume Dispatchers.IO works the same on every target. Pass a dispatcher into shared code:
class ItemRepository(
private val ioDispatcher: CoroutineDispatcher
) {
suspend fun load(): List<Item> =
withContext(ioDispatcher) {
readItems()
}
}This also makes tests easy.
Cancellation
Check Long Loops
Cancel works at suspend points. A long loop with no suspend call must check for cancel:
suspend fun processItems(items: List<Item>) {
for (item in items) {
currentCoroutineContext().ensureActive()
process(item)
}
}Do Not Hide Cancellation
If code catches a wide error type, send cancel errors back up:
try {
repository.fetch()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
showError(error)
}Clean Up with finally
viewModelScope.launch {
try {
_state.update { it.copy(isLoading = true) }
val data = repository.fetch()
_state.update { it.copy(data = data) }
} finally {
_state.update { it.copy(isLoading = false) }
}
}A finally block runs during cancel. Do not start long suspend work there.
If cleanup must suspend, use NonCancellable only for short, vital cleanup:
finally {
withContext(NonCancellable) {
closeSession()
}
}Collect in Android UI
In a Fragment or Activity, collect only while the UI is started:
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state)
}
}
}In Compose, prefer lifecycle-aware state collection when it is available:
val state by viewModel.uiState.collectAsStateWithLifecycle()Do not start a new collector on every screen update.
Tests
Use runTest. Pass test dispatchers into the code under test. Do not use real delays.
Test a Flow with Turbine
@Test
fun `search updates item list`() = runTest {
val repository = FakeItemRepository()
val viewModel = ItemListViewModel(repository)
viewModel.state.test {
assertEquals(ItemListState(), awaitItem())
viewModel.onSearch("cats")
assertTrue(awaitItem().isLoading)
repository.emit(
listOf(Item(id = "1", name = "Cat"))
)
val loaded = awaitItem()
assertFalse(loaded.isLoading)
assertEquals(1, loaded.items.size)
cancelAndIgnoreRemainingEvents()
}
}End the Turbine test after the needed checks. A hot flow does not end on its own.
Test Work That Runs in the Background
@Test
fun `parallel load fills the state`() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val viewModel = DashboardViewModel(
itemRepo = FakeItemRepo(),
statsRepo = FakeStatsRepo(),
dispatcher = dispatcher
)
viewModel.load()
advanceUntilIdle()
assertNotNull(viewModel.state.value.items)
assertNotNull(viewModel.state.value.stats)
}If a ViewModel uses Dispatchers.Main, set a test main dispatcher before the test and reset it after the test.
Fake a Flow Source
class FakeItemRepository : ItemRepository {
private val items =
MutableStateFlow<List<Item>>(emptyList())
override fun observeItems(): Flow<List<Item>> = items
fun emit(value: List<Item>) {
items.value = value.toList()
}
override suspend fun getItemsByCategory(
category: String
): Result<List<Item>> {
return Result.success(
items.value.filter { it.category == category }
)
}
}Copy input lists so later test changes do not change old state by mistake.
Full Usage Example
This ViewModel loads items from a search flow and keeps errors in UI state:
data class SearchState(
val query: String = "",
val items: List<Item> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
class SearchViewModel(
private val repository: ItemRepository
) : ViewModel() {
private val query = MutableStateFlow("")
val state: StateFlow<SearchState> = query
.debounce(300)
.map(String::trim)
.distinctUntilChanged()
.flatMapLatest { text ->
if (text.isBlank()) {
flowOf(SearchState(query = text))
} else {
repository.search(text)
.map<List<Item>, SearchState> { items ->
SearchState(
query = text,
items = items
)
}
.onStart {
emit(
SearchState(
query = text,
isLoading = true
)
)
}
.catch { error ->
if (error is CancellationException) throw error
emit(
SearchState(
query = text,
error = error.message ?: "Search failed"
)
)
}
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SearchState()
)
fun onQueryChange(value: String) {
query.value = value
}
}A new query stops the old search. A blank query clears the results. Cancel errors still pass through.
Avoid These Mistakes
- Do not use
GlobalScope. - Do not make a scope that no owner can cancel.
- Do not catch and hide
CancellationException. - Do not change a mutable list inside
StateFlow. - Do not expose
MutableStateFlowoutside its owner. - Do not use
flowOn(Dispatchers.Main)to choose where collection runs. - Do not put UI work above
flowOn. - Do not make a new flow during each Compose update without
remember. - Do not use
runBlockingin app code. - Do not use real time or real dispatchers in unit tests.
- Do not collect the same cold flow many times if the work should be shared.
- Do not assume a
SharedFlowsaves events for users who are not listening. - Do not update Android views from a worker dispatcher.
Related Skills
- Use
compose-multiplatform-patternsfor Flow use in Compose UI. - Use
android-clean-architecturefor coroutine rules across app layers.