Kotlin Coroutines & Flow: Mastering Async Android
By Christian Torrico · May 10, 2026
If there’s one thing that transformed how I write Android code, it’s Kotlin Coroutines and Flow. Before coroutines, async code in Android meant callbacks, RxJava chains, or AsyncTask (please, no). Today, async code reads almost like synchronous code — and that’s a game changer.
The Problem with Callbacks
Here’s what network code looked like before coroutines:
// Callback hell — hard to read, hard to handle errors
apiService.getUser(userId, object : Callback<User> {
override fun onSuccess(user: User) {
apiService.getPosts(user.id, object : Callback<List<Post>> {
override fun onSuccess(posts: List<Post>) {
// Update UI on main thread...
runOnUiThread {
adapter.submitList(posts)
}
}
override fun onFailure(error: Throwable) { /* handle */ }
})
}
override fun onFailure(error: Throwable) { /* handle */ }
})
Compare that to the coroutine version:
// Coroutines — reads like synchronous code
viewModelScope.launch {
try {
val user = apiService.getUser(userId) // suspends, doesn't block
val posts = apiService.getPosts(user.id) // waits for user first
_uiState.value = UiState.Success(posts)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message)
}
}
Understanding suspend Functions
A suspend function can be paused and resumed without blocking the thread. The compiler transforms them under the hood — no threads are created, no magic. They run on coroutine dispatchers:
Dispatchers.IO— for network calls and file I/ODispatchers.Main— for UI updates on AndroidDispatchers.Default— for CPU-intensive work
// Retrofit automatically generates suspend versions
interface UserApi {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): User
}
// Use withContext to switch dispatchers
suspend fun loadUserFromDisk(id: String): User = withContext(Dispatchers.IO) {
database.userDao().getUser(id) // runs on IO thread
}
Coroutine Scopes
Every coroutine needs a scope. In Android:
// ViewModels — automatically cancelled when ViewModel is cleared
viewModelScope.launch { ... }
// Fragments/Activities — cancelled when lifecycle is destroyed
lifecycleScope.launch { ... }
// Custom scope — you manage the lifecycle
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
Kotlin Flow
While coroutines are great for one-shot async operations, Flow is for streams of values over time. Think of it like a cold observable:
// A Flow emits values over time
fun getStockPrice(symbol: String): Flow<Double> = flow {
while (true) {
emit(api.fetchPrice(symbol)) // emit a new value
delay(5000) // wait 5 seconds
}
}
// Collecting a flow
viewModelScope.launch {
getStockPrice("GOOG")
.map { price -> "$${price.format(2)}" }
.catch { e -> emit("Error") }
.collect { priceText ->
_priceState.value = priceText
}
}
StateFlow and SharedFlow
These are the most commonly used flow types in Android apps:
// StateFlow — holds a single value, always has an initial state
// Perfect for UI state
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
// In Compose — collect as state
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// SharedFlow — for events that should be consumed once (like navigation)
private val _events = MutableSharedFlow<NavigationEvent>()
val events: SharedFlow<NavigationEvent> = _events.asSharedFlow()
fun navigateToProfile() {
viewModelScope.launch {
_events.emit(NavigationEvent.GoToProfile)
}
}
Combining Flows
Flow operators are incredibly powerful:
// Combine two flows — emits whenever either changes
val userName: StateFlow<String> = ...
val userAge: StateFlow<Int> = ...
val userSummary: Flow<String> = combine(userName, userAge) { name, age ->
"$name, $age years old"
}
// flatMapLatest — cancel previous request when new value arrives
searchQuery
.debounce(300) // wait for user to stop typing
.distinctUntilChanged() // only if query actually changed
.flatMapLatest { query ->
searchRepository.search(query)
}
.collect { results ->
_searchResults.value = results
}
Room + Flow = Reactive Database
One of my favorite combinations — Room queries can return Flow, which means your UI automatically updates when the database changes:
@Dao
interface MessageDao {
@Query("SELECT * FROM messages ORDER BY timestamp DESC")
fun getAllMessages(): Flow<List<Message>> // reactive!
}
// In ViewModel
val messages = messageDao.getAllMessages()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
Coroutines and Flow are now the standard in modern Android development. Once you internalize the mental model, you’ll find async programming genuinely enjoyable.