Kotlin Coroutines and Flow: Building Reactive Pipelines Without Callback Hell
Replace nested callbacks with Kotlin coroutines and Flow to build declarative, lifecycle‑aware reactive pipelines. A concrete repository‑to‑UI example shows the pattern and its trade‑offs.
03 Oct 2026, 06:08 UTC

The callback maze
When an Android app fetches a list of users from a remote API, then filters them locally, and finally updates the UI, the traditional approach nests callbacks: api.getUsers { users -> db.save(users) { db.query { filtered -> ui.show(filtered) } } }. Each level adds indentation, error‑handling duplication, and a mental map of which thread runs where. The result is fragile code that is hard to test and even harder to modify.
Coroutines as lightweight threads
Kotlin coroutines turn asynchronous work into sequential‑looking code. A coroutine is a suspendable computation that runs on a dispatcher (e.g., Dispatchers.IO for network or disk, Dispatchers.Main for UI). Because coroutines are cheap — thousands can run on a single thread — you can launch many without the overhead of native threads.
suspend fun fetchUsers(): List<User> = withContext(Dispatchers.IO) {
api.getUsers()
}
The suspend keyword tells the compiler that the function may pause without blocking the underlying thread. Callers simply write val users = fetchUsers() inside another coroutine.
Flow: cold, composable streams
A Flow<T> is a cold asynchronous stream: nothing happens until a terminal operator such as collect is invoked. This matches the mental model of “declare the pipeline, then run it”. Operators like map, filter, flatMapLatest, and distinctUntilChanged compose declaratively, and each step inherits the coroutine context of the collector.
fun userFlow(): Flow<List<User>> = flow {
emit(api.getUsers())
}.map { it.filter { it.isActive } }
.distinctUntilChanged()
Because the flow is cold, calling userFlow() returns instantly; the network request fires only when you collect.
Worked example: repository → UI
Assume a UserRepository exposing a flow of active users. The UI (a ViewModel or Activity) collects that flow in a lifecycle‑aware scope so collection stops automatically when the screen is destroyed.
class UserRepository(private val api: ApiService) {
fun activeUsers(): Flow<List<User>> = flow {
// network call runs on IO dispatcher
emit(api.getUsers())
}
.map { it.filter { it.isActive } }
.distinctUntilChanged()
.flowOn(Dispatchers.IO) // upstream runs on IO
}
class UserViewModel(private val repo: UserRepository) : ViewModel() {
val uiState: MutableStateFlow<UiState> = MutableStateFlow(UiState.Loading)
init {
viewModelScope.launch {
repo.activeUsers()
.catch { e -> uiState.value = UiState.Error(e) }
.collect { users ->
uiState.value = UiState.Success(users)
}
}
}
}
sealed interface UiState {
data class Success(val users: List<User>) : UiState
data class Error(val throwable: Throwable) : UiState
object Loading : UiState
}
Key points:
flowOn(Dispatchers.IO)moves the upstream emission to the IO dispatcher without affecting the collector’s context.viewModelScopeis aCoroutineScopetied to the ViewModel lifecycle; when the ViewModel is cleared, the collection cancels automatically.- The
catchoperator handles upstream exceptions without crashing the UI coroutine.
Trade‑offs
Flow shines when you need transformation, combination, or back‑pressure handling. For a single fire‑and‑forget request, a plain suspend function is simpler and avoids the extra boilerplate of creating a flow, adding operators, and managing a scope. Also, developers new to cold streams sometimes call launch { flow.collect { … } } expecting immediate execution, only to discover nothing runs until the flow is collected. Understanding the difference between launch (starts a coroutine) and collect (starts the flow) is essential.
Actionable tip
Start small: write a repository function that returns a Flow for one data source, collect it in a viewModelScope.launch, and observe the UI update. When the pipeline grows — multiple sources, debouncing, retry logic — refactor the collection into a dedicated suspend function that returns a Flow and keep the UI layer thin. This incremental approach lets you reap Flow’s composability without over‑engineering early on.
Verification steps
- Open the project in Android Studio (Kotlin 1.8+). Ensure
kotlinx-coroutines-coreandkotlinx-coroutines-androidare on the classpath. - Run the app on an emulator or device. Observe the UI transition from Loading → Success (or Error) as the flow emits.
- Rotate the screen; the ViewModel survives, and the flow continues without duplicate subscriptions.
- In a Kotlin REPL (or a unit test with
runTest), callrepo.activeUsers().take(1).toList()to confirm the flow emits the filtered list.
If the UI never leaves Loading, check that viewModelScope is not cancelled prematurely and that the API call succeeds (inspect logcat for network errors).
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.