Kotlin Coroutine Mistakes Every Android Developer Should Avoid

The Android world runs on coroutines now. They’ve made asynchronous programming cleaner, but a few subtle mistakes can lead to memory leaks, UI freezes, or silent crashes. I recently came across a fantastic summary of these pitfalls, and I wanted to break them down with simple, practical examples.

If you’re using ViewModelScope, LifecycleScope, or any other coroutine scope, pay close attention!


1. Mismanaging Coroutine Scope & Lifecycle

The Mistake: Using GlobalScope

Kotlin

// ❌ MISTAKE: GlobalScope misuse
GlobalScope.launch {
    fetchDataFromNetwork() // Lives beyond lifecycle
}

Using GlobalScope.launch means the coroutine will live as long as your application process—even if the UI component (like an Activity or ViewModel) that started it is destroyed. This causes memory leaks and unnecessary background work.

The Fix: Use a Lifecycle-Aware Scope

Always tie your coroutines to a specific scope that knows when to cancel itself.

  • For UI-related work that needs to survive configuration changes (e.g., fetching data): Use viewModelScope.
  • For work tied to the Activity/Fragment lifecycle: Use lifecycleScope.

Kotlin

// ✅ CORRECT: lifecycle-aware scope
viewModelScope.launch { 
    val result = fetchDataFromNetwork() 
} 


2. Blocking the Main Thread

The Mistake: Running Long Tasks on the Main Dispatcher

The Main Dispatcher (Dispatchers.Main) is for interacting with the UI. If you run a network request or a heavy database query on it, you freeze the user interface.

Kotlin

// ❌ MISTAKE: Blocking Main thread
runBlocking { // This block is an example of blocking the calling thread
    val result = fetchDataFromNetwork() // Freezes UI if called on Main
}

The Fix: Use the Proper Dispatcher

Always switch the context for blocking or I/O-intensive operations.

  • I/O Operations (Network, Database, File Reads): Use Dispatchers.IO.
  • CPU-Bound Operations (Complex calculations, JSON parsing): Use Dispatchers.Default.

Kotlin

// ✅ CORRECT: Use proper dispatcher
lifecycleScope.launch(Dispatchers.IO) { // Switch to IO for network
    val result = fetchDataFromNetwork() 
}


3. Ignoring Exceptions

The Mistake: Letting Exceptions Crash Silently

By default, exceptions thrown in a child coroutine within a launch block can propagate up and crash the app if not handled. Even worse, if you wrap a crashing call directly in a launch without a try/catch, the coroutine might crash silently or cancel its parent scope.

Kotlin

// ❌ MISTAKE: Ignoring exceptions
viewModelScope.launch { 
    val result = fetchDataFromNetwork() // Can crash silently
} 

The Fix: Catch Exceptions Inside the Coroutine

The most straightforward and often necessary way to handle errors is with a good old try/catch block.

Kotlin

// ✅ CORRECT: Exception handling
viewModelScope.launch {
    try {
        val result = fetchDataFromNetwork()
    } catch (e: Exception) {
        // Log the error and show a toast/snackbar to the user
        Log.e("CoroutineError", e.message.toString())
    }
}

Pro Tip: For more complex global error handling, especially across many coroutines, look into using a dedicated CoroutineExceptionHandler.


4. Forgetting to Cancel Jobs

The Mistake: Not Tying Job to a Lifecycle

If a long-running coroutine job isn’t tied to a proper scope (which handles cancellation automatically), you risk it running indefinitely. Even when using scopes, if you create a detached Job, you must manage it manually.

Kotlin

// ❌ MISTAKE: Not cancelling jobs
val job = viewModelScope.launch { fetchDataContinuously() }
// No cancel call, keeps running even after ViewModel is cleared

The Fix: Let the Scope Handle Cancellation

By using built-in scopes like viewModelScope or lifecycleScope, the coroutine is automatically cancelled when the scope’s parent is destroyed (ViewModel.onCleared() or Lifecycle.onDestroy()).

If you do have a manual Job, make sure to call .cancel() at the appropriate time:

Kotlin

// Example demonstrating manual cancel if needed
class MyViewModel: ViewModel() {
    private var continuousDataJob: Job? = null

    fun startDataFetch() {
        continuousDataJob = viewModelScope.launch { fetchDataContinuously() }
    }

    override fun onCleared() {
        continuousDataJob?.cancel() // ✅ CORRECT: cancel when lifecycle ends
        super.onCleared()
    }
}


5. Using the Wrong Dispatcher for Heavy CPU Work

The Mistake: Running Heavy Computation on the Main or I/O Dispatcher

Just like network calls shouldn’t be on the Main thread, heavy number-crunching shouldn’t be on the Dispatchers.IO thread pool, which is optimized for waiting (Input/Output). Running it on Main is the worst, as it locks the UI.

Kotlin

// ❌ MISTAKE: Dispatchers.Main for CPU-heavy tasks
viewModelScope.launch(Dispatchers.Main) { 
    val result = heavyComputation() // Freezes UI
} 

The Fix: Use Dispatchers.Default

For anything that is pure CPU-intensive—like sorting a massive list, complex calculations, or image processing—use Dispatchers.Default. This dispatcher is backed by a shared pool of worker threads, optimally tuned for CPU utilization.

Kotlin

// ✅ CORRECT: Use Dispatchers.Default for CPU-heavy tasks
viewModelScope.launch(Dispatchers.Default) { 
    val result = heavyComputation() 
    // Example: calculating a Fibonacci sequence
    val fibResult = fibonacci(10)
    println("Output: $fibResult") 
}


Final Thoughts

Coroutines are a superpower, but only when used correctly. The key takeaway is: Always be mindful of your scope and your dispatcher.

  • Scope = When the coroutine should run and stop.
  • Dispatcher = Where (on which thread) the coroutine should run.

Avoiding these five common mistakes will lead to a more stable, performant, and memory-efficient Android app. Happy coding!

Leave a Reply

Your email address will not be published. Required fields are marked *