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)
}

🧵 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
}

🚀 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()
}

🔹 async: returns a Deferred

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

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"
    }
}

🔄 withContext: for switching threads

Switch coroutine execution to a different dispatcher.

withContext(Dispatchers.IO) {
    val data = fetchData()
    withContext(Dispatchers.Main) {
        updateUI(data)
    }
}

✅ 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")
    }
}

✅ Use CoroutineExceptionHandler for top-level coroutines

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

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

📱 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")
    }
}

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