DEV Community

Cover image for Mastering Kotlin Coroutines in Android: A Practical Guide
Mohit Rajput
Mohit Rajput

Posted on

Mastering Kotlin Coroutines in Android: A Practical Guide

Modern Android development is all about writing clean, efficient, and asynchronous code β€” and Kotlin Coroutines have become the go-to tool for that. If you're tired of callback hell and want to write non-blocking, readable code, coroutines are your best friend.

In this post, we'll cover:

  • βœ… What are Coroutines?
  • πŸ” Coroutine vs Thread
  • 🧭 Coroutine Scope
  • πŸš€ launch vs async
  • πŸ”„ withContext
  • ⚠️ Exception handling
  • πŸ“± Real-world Android examples

🌱 What is a Coroutine?

A coroutine is a lightweight thread that can be suspended and resumed. It allows you to perform long-running tasks like network calls or database operations without blocking the main thread.

Coroutine = Co + routine i.e. it's the cooperation among routines(functions).

Think of it as a function that can pause mid-way and resume later, keeping your UI responsive.

GlobalScope.launch {
    val data = fetchDataFromNetwork()
    updateUI(data)
}
Enter fullscreen mode Exit fullscreen mode

🧡 Coroutine vs Thread

Feature Coroutine Thread
Lightweight βœ… Yes ❌ No (heavy OS object)
Performance πŸš€ High (thousands at once) 🐌 Limited (few hundred)
Blocking ❌ Non-blocking ❗ Blocking
Context Switching ✨ Easy with withContext ⚠️ Complex
Cancellation βœ… Scoped and structured ❌ Manual and error-prone

Coroutines don’t create new threads β€” they efficiently use existing ones via dispatchers.


🧭 Coroutine Scope

A CoroutineScope defines the lifecycle of a coroutine. If the scope is canceled, so are all its coroutines.

Common scopes:

  • GlobalScope: Application-wide (⚠️ Avoid in Android)
  • lifecycleScope: Tied to Activity/Fragment
  • viewModelScope: Tied to ViewModel lifecycle
viewModelScope.launch(Dispatchers.IO) {
    val user = userRepository.getUser()
    _userState.value = user
}
Enter fullscreen mode Exit fullscreen mode

πŸš€ launch vs async

Both start coroutines, but differ in intent:

πŸ”Ή launch: fire-and-forget

  • Doesn’t return a result
  • Ideal for background tasks
launch {
    saveDataToDb()
}
Enter fullscreen mode Exit fullscreen mode

πŸ”Ή async: returns a Deferred

  • Used when you need a result
val deferred = async {
    fetchDataFromApi()
}
val result = deferred.await()
Enter fullscreen mode Exit fullscreen mode

You can call functions concurrently using async. Here is the example:

class UserViewModel : ViewModel() {

    private val _userInfo = MutableLiveData<String>()
    val userInfo: LiveData<String> get() = _userInfo

    fun loadUserData() {
        viewModelScope.launch {
            val userDeferred = async { fetchUser() }
            val settingsDeferred = async { fetchUserSettings() }

            try {
                val user = userDeferred.await()
                val settings = settingsDeferred.await()

                _userInfo.value = "User: $user, Settings: $settings"
            } catch (e: Exception) {
                _userInfo.value = "Error: ${e.message}"
            }
        }
    }

    // Simulated suspending functions
    private suspend fun fetchUser(): String {
        delay(1000) // Simulate network/API delay
        return "Alice"
    }

    private suspend fun fetchUserSettings(): String {
        delay(1200) // Simulate network/API delay
        return "Dark Mode"
    }
}

Enter fullscreen mode Exit fullscreen mode

πŸ”„ withContext: for switching threads

Switch coroutine execution to a different dispatcher.

withContext(Dispatchers.IO) {
    val data = fetchData()
    withContext(Dispatchers.Main) {
        updateUI(data)
    }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Use withContext for sequential tasks. Prefer it over async/await when there's no concurrency benefit.


⚠️ Exception Handling in Coroutines

βœ… Use try-catch inside coroutine blocks

viewModelScope.launch(Dispatchers.IO) {
    try {
        val result = repository.getData()
        _dataLiveData.postValue(result)
    } catch (e: Exception) {
        _errorLiveData.postValue("Something went wrong")
    }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Use CoroutineExceptionHandler for top-level coroutines

val exceptionHandler = CoroutineExceptionHandler { _, exception ->
    Log.e("CoroutineError", "Caught $exception")
}

viewModelScope.launch(Dispatchers.IO + exceptionHandler) {
    throw RuntimeException("Oops!")
}
Enter fullscreen mode Exit fullscreen mode

πŸ“± Real-world Example (Network + DB)

viewModelScope.launch(Dispatchers.Main) {
    try {
        val user = withContext(Dispatchers.IO) {
            val networkUser = apiService.fetchUser()
            userDao.insertUser(networkUser)
            networkUser
        }
        _userLiveData.postValue(user)
    } catch (e: Exception) {
        _errorLiveData.postValue("Failed to load user")
    }
}
Enter fullscreen mode Exit fullscreen mode

🧼 Best Practices

  • Always use viewModelScope or lifecycleScope, not GlobalScope
  • Use Dispatchers.IO for heavy I/O tasks (network, DB)
  • Use withContext for sequential switching
  • Catch exceptions explicitly
  • Avoid blocking calls like Thread.sleep() inside coroutines

πŸ“š Final Thoughts

Kotlin Coroutines are powerful, concise, and align beautifully with modern Android architecture. Once you embrace them, you’ll write faster, cleaner, and more maintainable asynchronous code.


✍️ Enjoyed this post? Drop a ❀️, share it with your Android dev circle, or follow me for more practical guides.

Got questions or want advanced coroutine topics like Flow, SupervisorJob? Let me know in the comments! I will cover that in the next blog.

Top comments (0)