Kotlin Coroutines for Android — Suspend Functions, Scopes and Dispatchers Guide

Writing asynchronous code in Android used to mean callbacks, RxJava chains, or AsyncTasks — all of which made code hard to read, harder to maintain, and full of subtle bugs. Kotlin Coroutines changed everything.

Coroutines let you write asynchronous code that looks synchronous — no callback nesting, no thread management boilerplate. They're now the standard way to handle background work in Android, built directly into Jetpack's ViewModel and Lifecycle libraries.

In this guide you'll learn everything you need to use coroutines confidently in Android: suspend functions, coroutine builders, dispatchers, scopes, error handling, and the async/await pattern.

Kotlin Coroutines for Android Development

Kotlin Coroutines — Credit: Kotlin GitHub

1. Add Coroutines Dependencies

Add these to your build.gradle (app):

dependencies {
    // Kotlin Coroutines
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"

    // Lifecycle KTX — provides viewModelScope and lifecycleScope
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.0"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0"
}

2. Suspend Functions — The Foundation

A suspend function can be paused and resumed without blocking the thread. It can only be called from another suspend function or from a coroutine. This is the key primitive that makes coroutines work:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

// suspend keyword = this function can be paused and resumed
suspend fun fetchUserFromNetwork(userId: String): User {
    // withContext switches to IO thread for network/disk operations
    return withContext(Dispatchers.IO) {
        apiService.getUser(userId) // Suspends here, doesn't block the thread
    }
}

// Regular function — can NOT call suspend functions directly
fun regularFunction() {
    // fetchUserFromNetwork("123") // ❌ Compile error
}

// Suspend function — CAN call other suspend functions
suspend fun loadUserProfile(userId: String): UserProfile {
    val user = fetchUserFromNetwork(userId)     // ✅ OK
    val posts = fetchUserPosts(userId)          // ✅ OK
    return UserProfile(user, posts)
}

3. Dispatchers — Which Thread to Run On

Dispatchers control which thread your coroutine runs on. Choosing the right dispatcher is critical for performance and avoiding ANR errors:

Dispatcher Thread Use For
Dispatchers.Main Main/UI thread UI updates, observing LiveData
Dispatchers.IO Background thread pool Network calls, database, file I/O
Dispatchers.Default CPU-optimised thread pool CPU-intensive work, sorting, parsing JSON
Dispatchers.Unconfined Caller's thread Testing only — avoid in production
import kotlinx.coroutines.*

suspend fun example() {
    // Network call — use IO dispatcher
    val data = withContext(Dispatchers.IO) {
        apiService.fetchData()
    }

    // Heavy computation — use Default dispatcher
    val sorted = withContext(Dispatchers.Default) {
        data.sortedBy { it.name }
    }

    // UI update — back on Main (happens automatically in viewModelScope)
    binding.tvResult.text = sorted.first().name
}

4. Coroutine Builders — launch vs async

There are two main ways to start a coroutine:

  • launch — fire and forget. Returns a Job. Use when you don't need a result.
  • async — returns a Deferred<T>. Use when you need a result via .await().
import kotlinx.coroutines.*

// launch — fire and forget, returns Job
viewModelScope.launch {
    updateDatabase()  // We don't need the return value
}

// async/await — parallel execution, returns result
viewModelScope.launch {
    // Start both requests simultaneously (parallel)
    val userDeferred = async(Dispatchers.IO) { apiService.getUser("123") }
    val postsDeferred = async(Dispatchers.IO) { apiService.getPosts("123") }

    // Wait for both to complete
    val user = userDeferred.await()
    val posts = postsDeferred.await()

    // Both are now available
    updateUI(user, posts)
}

// Sequential vs Parallel comparison
viewModelScope.launch {
    // Sequential — total time = time(A) + time(B)
    val a = fetchA()  // waits for A to finish
    val b = fetchB()  // then waits for B

    // Parallel — total time = max(time(A), time(B))
    val aDeferred = async { fetchA() }
    val bDeferred = async { fetchB() }
    val (a, b) = Pair(aDeferred.await(), bDeferred.await())
}

5. Coroutine Scopes — viewModelScope and lifecycleScope

A coroutine scope defines the lifetime of a coroutine. Always use a structured scope — never GlobalScope in production:

Kotlin Coroutines Job Lifecycle

Kotlin Coroutines Job Lifecycle

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch

// In ViewModel — use viewModelScope
class MyViewModel : ViewModel() {

    fun loadData() {
        // Automatically cancelled when ViewModel is cleared
        viewModelScope.launch {
            val data = fetchDataFromNetwork()
            _uiState.value = data
        }
    }
}

// In Activity/Fragment — use lifecycleScope
class MyFragment : Fragment() {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // Automatically cancelled when Fragment view is destroyed
        viewLifecycleOwner.lifecycleScope.launch {
            // repeatOnLifecycle — only runs when UI is STARTED
            // Automatically pauses when app goes to background
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    updateUI(state)
                }
            }
        }
    }
}

6. Error Handling

Always handle errors in coroutines. Unhandled exceptions in launch will crash your app:

import kotlinx.coroutines.*

// Option 1: try/catch inside the coroutine (recommended)
viewModelScope.launch {
    try {
        val data = apiService.fetchData()
        _items.value = data
    } catch (e: IOException) {
        _error.value = "Network error: ${e.message}"
    } catch (e: Exception) {
        _error.value = "Unexpected error: ${e.message}"
    }
}

// Option 2: CoroutineExceptionHandler for launch blocks
val handler = CoroutineExceptionHandler { _, exception ->
    Log.e("Coroutine", "Caught exception: $exception")
    _error.value = exception.message
}

viewModelScope.launch(handler) {
    val data = apiService.fetchData() // If this throws, handler catches it
    _items.value = data
}

// Option 3: runCatching — returns Result<T>
viewModelScope.launch {
    val result = runCatching { apiService.fetchData() }
    result
        .onSuccess { data -> _items.value = data }
        .onFailure { error -> _error.value = error.message }
}

7. Real-World Example — Fetching Data in a ViewModel

Here's a complete example tying everything together — fetching data from an API, handling loading state, and error state using StateFlow in a ViewModel:

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

sealed class UiState {
    object Loading : UiState()
    data class Success(val items: List<Item>) : UiState()
    data class Error(val message: String) : UiState()
}

class ItemsViewModel(
    private val repository: ItemsRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState

    init {
        loadItems()
    }

    fun loadItems() {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val items = repository.getItems() // suspend function
                _uiState.value = UiState.Success(items)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

// In Fragment
viewLifecycleOwner.lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            when (state) {
                is UiState.Loading -> showLoading()
                is UiState.Success -> showItems(state.items)
                is UiState.Error   -> showError(state.message)
            }
        }
    }
}

Best Practices

  • Never use GlobalScope in production — it's not tied to any lifecycle and will keep running even after the screen is gone, causing memory leaks. Always use viewModelScope or lifecycleScope.
  • Use viewModelScope for data operations — put your network calls, database queries, and business logic in the ViewModel using viewModelScope. It's automatically cancelled when the ViewModel is cleared.
  • Use repeatOnLifecycle when collecting flows — wrap flow collection in repeatOnLifecycle(STARTED) inside Fragments. This pauses collection when the app goes to background, saving battery and preventing crashes.
  • Use withContext instead of launching new coroutines — for switching dispatchers within a suspend function, withContext(Dispatchers.IO) is cleaner and more efficient than launching a new coroutine.
  • Always handle exceptions — use try/catch or runCatching inside your coroutines. An unhandled exception in a launch block will crash your app.
  • Use async for parallelism — when two independent network calls are needed, use async { } + await() to run them simultaneously instead of sequentially.

Frequently Asked Questions

What is the difference between launch and async in Kotlin coroutines?
launch is fire-and-forget — it starts a coroutine and returns a Job with no result. async starts a coroutine that returns a Deferred value. Call .await() on a Deferred to get the result. Use async when you need a return value, especially for parallel execution.

What is the difference between viewModelScope and lifecycleScope?
viewModelScope is tied to the ViewModel's lifecycle — cancelled when the ViewModel is cleared. Use it for data fetching and business logic. lifecycleScope is tied to the Activity or Fragment lifecycle — cancelled when the component is destroyed. Use it for UI-related coroutines and collecting flows.

What is a suspend function in Kotlin?
A suspend function can be paused and resumed without blocking the thread it runs on. It can only be called from another suspend function or from within a coroutine. The suspend keyword tells the Kotlin compiler to generate the state machine code needed to pause and resume execution.

Should I use Kotlin Coroutines or RxJava?
For new Android projects, use Kotlin Coroutines and Flow. They're officially recommended by Google, built into Jetpack libraries, and result in simpler, more readable code. RxJava is still valid for existing codebases but has a much steeper learning curve.

📚 Continue Learning
📝 Summary
  • suspend — marks a function that can pause and resume without blocking
  • Dispatchers.IO — network and disk operations; Dispatchers.Default — CPU work; Dispatchers.Main — UI updates
  • launch — fire and forget; async/await — parallel with result
  • viewModelScope — for data operations in ViewModel; lifecycleScope — for UI operations in Activity/Fragment
  • Use repeatOnLifecycle(STARTED) when collecting flows in Fragments
  • Always handle exceptions with try/catch or runCatching
  • Never use GlobalScope in production — always use structured concurrency

Pragnesh Ghoda

A forward-thinking developer offering more than 8 years of experience building, integrating, and supporting android applications for mobile and tablet devices on the Android platform. Talks about #kotlin and #android

1 Comments

Please let us know about any concerns or query.

Previous Post Next Post

Contact Form