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.
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:
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
viewModelScopeorlifecycleScope. - 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
runCatchinginside your coroutines. An unhandled exception in alaunchblock 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.
- Retrofit with Kotlin Coroutines in Android - The Complete Guide
- ViewModel State Management — LiveData, StateFlow & SavedStateHandle
- Android ViewModel Example — Build Your First ViewModel
- Step 7: LiveData and ViewModel — Smarter Data Handling
- RecyclerView with Endless Scrolling — Retrofit + Coroutines
- Firebase Remote Config in Android with Kotlin
- 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
thank you
ReplyDelete