← All posts

Kotlin Coroutines: Async Android Without Callback Hell

Coroutines make async code in Android feel like regular sequential code. Here is how suspend functions, viewModelScope, and structured concurrency actually work.

Michael Cooper5 min readGreen belt

If you've spent any time with Android development, you know the pain of managing background work. Threads, AsyncTask (now deprecated), RxJava chains, and nested callbacks all get the job done — but they make even straightforward network calls feel ceremonial. Kotlin coroutines are the answer: async code that reads like synchronous code, cancels cleanly, and integrates directly with Android's lifecycle.

This post covers the practical patterns that come up most often.

The Setup

Add these to your build.gradle.kts (app module), using the latest versions available:

kotlin
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
    // lifecycle-viewmodel-ktx gives you viewModelScope for free
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
}

What suspend Actually Means

A suspend function can pause its execution and resume later without blocking the thread. The compiler rewrites it into a state machine under the hood — you never see that complexity.

kotlin
// This looks synchronous but doesn't block
suspend fun fetchUser(id: String): User {
    return api.getUser(id)  // suspends here while waiting for the network
}

The key rule: you can only call a suspend function from another suspend function or from a coroutine. That's where coroutine scopes come in.

Coroutine Scopes and viewModelScope

A scope defines the lifetime of a coroutine. When the scope is cancelled, every coroutine inside it is cancelled too. This is the core of structured concurrency — no fire-and-forget orphans leaking memory or crashing after the user navigated away.

In a ViewModel, always use viewModelScope:

kotlin
class ProfileViewModel(private val repo: UserRepository) : ViewModel() {

    private val _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> = _user.asStateFlow()

    private val _error = MutableStateFlow<String?>(null)
    val error: StateFlow<String?> = _error.asStateFlow()

    fun loadUser(id: String) {
        viewModelScope.launch {
            try {
                _user.value = repo.getUser(id)
            } catch (e: Exception) {
                _error.value = e.message
            }
        }
    }
}

viewModelScope is automatically cancelled when the ViewModel is cleared (i.e., when the Fragment or Activity it belongs to is destroyed for good). You never need to cancel it manually.

For Fragment or Activity code, use lifecycleScope:

kotlin
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.user.collect { user ->
            user?.let { binding.nameText.text = it.name }
        }
    }
}

repeatOnLifecycle stops collecting when the screen goes to the background and restarts when it returns. In a Fragment, use viewLifecycleOwner.lifecycleScope and viewLifecycleOwner.repeatOnLifecycle so collection follows the view's lifecycle.

Switching Threads with withContext

Coroutines have dispatchers that control which thread they run on:

  • Dispatchers.Main — the Android main/UI thread
  • Dispatchers.IO — a shared pool optimized for blocking I/O (network, disk)
  • Dispatchers.Default — CPU-intensive work (sorting, JSON parsing)

withContext suspends the current coroutine, switches dispatcher, does the work, then resumes back on the original dispatcher:

kotlin
class UserRepository(private val dao: UserDao, private val api: ApiService) {

    suspend fun getUser(id: String): User {
        // Called from viewModelScope (Main) — switch to IO for disk + network
        return withContext(Dispatchers.IO) {
            val cached = dao.findById(id)
            if (cached != null) return@withContext cached

            val remote = api.fetchUser(id)
            dao.insert(remote)
            remote
        }
        // Back on Main automatically
    }
}

The ViewModel calls this from viewModelScope.launch (which runs on Main by default), and the repository does its work on the IO dispatcher. Room's and Retrofit's suspend functions are already main-safe, so withContext is only strictly needed around blocking calls, but keeping the switch in the repository makes the boundary explicit.

launch vs async/await

Use launch when you want fire-and-handle-result-via-state. Use async when you want to run two things concurrently and combine their results.

kotlin
// Sequential — takes networkCallA + networkCallB time total
fun loadSequential() {
    viewModelScope.launch {
        val profile = repo.getProfile(userId)
        val posts = repo.getPosts(userId)
        // ...
    }
}

// Concurrent — takes max(networkCallA, networkCallB) time
fun loadConcurrent() {
    viewModelScope.launch {
        try {
            coroutineScope {
                val profileDeferred = async { repo.getProfile(userId) }
                val postsDeferred = async { repo.getPosts(userId) }

                val profile = profileDeferred.await()
                val posts = postsDeferred.await()
                // ...
            }
        } catch (e: IOException) {
            _error.value = e.message
        }
    }
}

Both getProfile and getPosts start immediately in the concurrent version. If either fails, coroutineScope cancels the other and rethrows the exception, so the try/catch can handle it. Without that wrapper, a failed async cancels its parent coroutine, and catching around await() alone won't stop the failure from propagating.

Error Handling

The try/catch inside launch is the simplest approach:

kotlin
viewModelScope.launch {
    try {
        _uiState.value = UiState.Loading
        val result = repo.getData()
        _uiState.value = UiState.Success(result)
    } catch (e: IOException) {
        _uiState.value = UiState.Error("Network error: ${e.message}")
    } catch (e: HttpException) {
        _uiState.value = UiState.Error("Server error ${e.code()}")
    }
}

For one-off suspend calls that might fail, runCatching gives a clean Result type:

kotlin
val result = runCatching { repo.getUser(id) }
result.fold(
    onSuccess = { _user.value = it },
    onFailure = { _error.value = it.message }
)

Be careful with broad catches. Cancellation is delivered as a CancellationException, and both catch (e: Exception) and runCatching will catch it too. Rethrow it, or catch specific exception types as in the first example, so the coroutine can still cancel properly.

A Note on Flow

For anything that produces multiple values over time — live database queries, WebSocket messages, paginated lists — look into Flow. A suspend function returns one value; a Flow is a cold stream of values. Room's DAO can return Flow<List<User>> directly, and your ViewModel collects it into StateFlow using stateIn. That's a full post on its own, but knowing the boundary helps: one-shot work → suspend function, stream → Flow.

Wrapping Up

A pattern that covers most Android async work:

  1. ViewModel calls viewModelScope.launch { ... }
  2. Repository functions are suspend and use withContext(Dispatchers.IO) around blocking I/O
  3. Concurrent independent calls use async { }.await() inside coroutineScope
  4. Errors are caught with try/catch and surfaced via StateFlow

The result is async code you can read top-to-bottom, test with runTest, and tie to the lifecycle of the screen that started it.