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