Kotlin Coroutines & Flow: Mastering Async Android

By Christian Torrico  ·  May 10, 2026

Kotlin Coroutines Flow Async Android
Kotlin Coroutines & Flow: Mastering Async Android

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:

// 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.

Back to all posts