Coroutines & Flows: 5 Critical Anti-Patterns That Are Secretly Slowing Down Your Android App

 Stop sneaky performance killers. Spot and fix Kotlin issues that cause crashes, memory leaks, and laggy UIs.


Stop sneaky performance killers. Spot and fix Kotlin issues that cause crashes, memory leaks, and laggy UIs.

Coroutines and Kotlin Flows are the bedrock of modern asynchronous programming on Android. They allow us to write clean, sequential-looking code that manages complex background operations, network calls, and database transactions.

But power comes with responsibility.

Many developers, despite using Coroutines daily, unknowingly introduce subtle mistakes — anti-patterns — that lead to memory leaks, silent crashes, data loss, and UI jank.

Based on insights from top-tier Android development, here are five critical Coroutine and Flow anti-patterns you must identify and fix today, plus one bonus pitfall I often see in code reviews.

1. The Main Thread Trap: Heavy Work in viewModelScope

When you launch a coroutine inside a ViewModel using viewModelScope.launch { ... }, where do you think that work runs by default?

The Main Thread!

If your coroutine contains heavy computation, such as complex data mapping, filtering massive lists, or complex business logic, you are effectively freezing the UI, even though you think you’re “on a coroutine.”

❌ Anti-Pattern Example (Blocking the UI)

Imagine a financial app that processes thousands of transactions.

class TransactionViewModel(private val repository: TransactionRepository) : ViewModel() {

fun loadAndProcessTransactions() {
// ⚠️ ANTI-PATTERN: Runs on Dispatchers.Main by default.
// Heavy processing will block the UI and cause frame drops.
viewModelScope.launch {
val rawData = repository.getTransactionsStream().first()

// This is the CPU-intensive block that will cause jank
val processed = rawData
.filter { it.amount > 1000 }
.sortedByDescending { it.date }
.map { it.toUiModel() }

_uiState.value = processed
}
}
}

✅ Best Practice: Use Dispatchers.Default for CPU Work

For any CPU-bound work (like complex sorting, filtering, JSON parsing, or image processing), always explicitly switch to Dispatchers.Default.

class TransactionViewModel(...) : ViewModel() {

fun loadAndProcessTransactions() {
// ✅ FIX: Move CPU-intensive work to a background thread pool.
viewModelScope.launch(Dispatchers.Default) {
val rawData = repository.getTransactionsStream().first()

val processed = rawData
.filter { it.amount > 1000 }
.sortedByDescending { it.date }
.map { it.toUiModel() }

// The StateFlow update is safe as it will jump back to Main implicitly
_uiState.value = processed
}
}
}

2. The Premature Cancellation Trap (Navigation Race)

One of the most common causes of silent data loss is a race condition between a user action and UI navigation.

❌ Anti-Pattern Example (Data Loss)

A user fills out a form to upload a large configuration file. Upon hitting “Save,” the app immediately navigates back to the previous screen.

// In the Fragment/Screen
saveButton.setOnClickListener {
viewModel.saveConfiguration(data)
findNavController().navigateUp() // ⚠️ ANTI-PATTERN: Navigate immediately!
}

// In the ViewModel
fun saveConfiguration(data: ConfigData) {
// Launched in viewModelScope, which is tied to the ViewModel's life.
viewModelScope.launch {
// This network call takes 5 seconds to upload the large file.
configRepository.uploadFile(data)
// If the ViewModel is cleared (due to navigateUp()), this coroutine is CANCELLED.
// The upload stops, and the file is not saved.
}
}

✅ Best Practice: Let the Coroutine Control Navigation

The ViewModel should be the one to signal the UI that the operation is complete.

Solution A: Use One-Time Events (Recommended)

Use a Channel to send a single navigation event after the operation is complete.

// In the ViewModel
private val _events = Channel<ConfigEvent>()
val events = _events.receiveAsFlow()

fun saveConfiguration(data: ConfigData) {
viewModelScope.launch {
configRepository.uploadFile(data)
// ✅ FIX: Only signal navigation AFTER the file is successfully uploaded.
_events.send(ConfigEvent.Navigation.GoBack)
}
}

// In the Fragment/Screen, collect events and then navigate
lifecycleScope.launch {
viewModel.events.collect { event ->
if (event == ConfigEvent.Navigation.GoBack) {
findNavController().navigateUp()
}
}
}

Solution B: Use an Application-Bound Scope (For Non-UI Critical Work)

For tasks that must finish (e.g., logging, crash reporting, background sync) even if the UI is gone, use a custom, application-scoped CoroutineScope.

3. The Illusion of Concurrency: Sequential Joins

The entire point of coroutines is to run tasks concurrently when they don’t depend on each other. But using join() (or await() on async) incorrectly can force your parallel tasks to run one after the other.

❌ Anti-Pattern Example (Wasted Time)

Fetching a user’s profile and their friends list — two independent network calls — is done sequentially.

suspend fun fetchDashboardData(userId: String): DashboardData = coroutineScope {
// ⚠️ ANTI-PATTERN: Immediate join forces sequential execution.
val profileJob = launch {
profile = api.fetchProfile(userId)
}
profileJob.join() // Suspend until profile is done!

val friendsJob = launch {
friends = api.fetchFriends(userId) // This only starts AFTER profileJob completes
}
friendsJob.join() // Suspend until friends is done!

// The total time taken is ProfileTime + FriendsTime.
return DashboardData(profile, friends)
}

✅ Best Practice: Launch All, Then Join All

Launch all the independent tasks first to get them running concurrently, and only then call join() or await() to wait for the results.

suspend fun fetchDashboardData(userId: String): DashboardData = coroutineScope {

// ✅ FIX: Launch both concurrently. They start executing immediately.
val profileJob = launch {
profile = api.fetchProfile(userId)
}

val friendsJob = launch {
friends = api.fetchFriends(userId)
}

// Now, suspend the parent coroutine until BOTH complete.
// The total time taken is max(ProfileTime, FriendsTime).
profileJob.join()
friendsJob.join()

return DashboardData(profile, friends)
}

4. The Silent Killer: Catching CancellationException

This is perhaps the most insidious anti-pattern, as it results in bugs that are difficult to trace and fix. Coroutines use the CancellationException to signal when a coroutine has been canceled, allowing the system to clean up and stop work.

If you catch this exception with a generic catch(e: Exception) block, you "eat" the signal, and the coroutine might never actually stop.

❌ Anti-Pattern Example (Infinite Loop/Resource Leak)

A background polling task that is stopped by rotating the screen (which cancels the coroutine).

suspend fun startPolling() {
while (true) {
try {
// Check current configuration from server every 5 seconds
api.checkConfigVersion()
delay(5000L) // This is a cancellable suspending function
} catch (e: Exception) {
// ⚠️ ANTI-PATTERN: Catches CancellationException!
// This 'eats' the signal, the coroutine is never fully canceled,
// and the 'while(true)' loop re-runs immediately.
Log.e("Polling", "Error during poll: ${e.message}")
}
}
}

✅ Best Practice: Never Catch Cancellation (or Re-throw It)

You have two options:

Option A: Catch Specific Exceptions (Recommended) Only catch exceptions you intend to handle (e.g., IOException for network errors).

// ...
} catch (e: IOException) {
Log.e("Polling", "Network error: ${e.message}")
}
// The CancellationException is allowed to propagate up and stop the loop.

Option B: Re-throw the Cancellation Signal If you must use a generic catch(e: Exception), re-throw the cancellation.

} catch (e: Exception) {
// ✅ FIX: Check the coroutine context for an active cancellation.
coroutineContext.ensureActive()
// This will re-throw the CancellationException if the coroutine is cancelled.
Log.e("Polling", "Generic error: ${e.message}")
}

5. The Context Inheritance Failure: Misusing SupervisorJob

The purpose of a SupervisorJob is to allow child coroutines to fail independently. If child A throws an exception, child B should continue running.

This behavior is true for CoroutineScopes built with a SupervisorJob (like viewModelScope), but it fails when you pass it directly to a launch block.

❌ Anti-Pattern Example (Unintended Failure)

Two critical logging tasks are launched, but one fails and cancels the other.

fun startIndependentTasks() {
// ⚠️ ANTI-PATTERN: Passing SupervisorJob directly to 'launch' block.
// This Job is not inherited by the inner coroutines.
viewModelScope.launch(SupervisorJob()) {

// This will cancel the entire scope (including the second launch) on failure
launch {
api.logSystemData() // Throws an exception
}

launch {
api.logUserData() // CANCELED due to sibling failure!
}
}
}

✅ Best Practice: Use supervisorScope

The supervisorScope suspending function is specifically designed to create an inner scope where child coroutines can fail independently, without affecting their siblings.

fun startIndependentTasks() {
viewModelScope.launch {
// ✅ FIX: Use supervisorScope to contain the independently failing children.
supervisorScope {

// This launch fails...
launch {
api.logSystemData()
}

// ...but this launch continues to run successfully.
launch {
api.logUserData()
}
}
}
}

🔥 Over-Scoping with GlobalScope

While not a technical flaw, using GlobalScope for everything is a huge architectural mistake and an anti-pattern.

GlobalScope.launch { ... } creates a top-level coroutine that is never canceled until the application process is killed. This makes it impossible to manage.

The result: Memory leaks (if it holds a reference to a Context or View), resource leaks, and background work continuing long after it's relevant (e.g., after the user leaves the screen).

Always prefer:

  • viewModelScope for ViewModel logic.
  • lifecycleScope for Activity/Fragment logic.
  • CoroutineScope(Dispatchers.IO + SupervisorJob()) for application-level components (e.g., a data synchronizer).

Frequently Asked Questions (FAQs)

What is the difference between Dispatchers.Default and Dispatchers.IO?

Dispatchers.Default is for CPU-bound work (e.g., sorting, large list processing, complex calculations). It is backed by a limited, shared pool of threads. Dispatchers.IO is for I/O-bound work (e.g., network calls, database operations, reading files). It is backed by a larger, on-demand pool of threads. Use the correct one to avoid resource contention.

Why should I use coroutineScope inside a suspend function?

coroutineScope creates a scope that inherits the parent coroutine's context and waits for all its children to complete before completing itself. If any child fails, it immediately fails the parent and cancels all other children. It’s perfect for ensuring all related parallel work completes successfully.

When should I use SupervisorJob versus a standard Job?

A standard Job is structured: if a child fails, the parent fails, and all other children fail (like a chain reaction). A SupervisorJob is unstructured: if a child fails, it does not affect its siblings. Use SupervisorJob or supervisorScope when you have independent tasks that should not be canceled by the failure of a sibling.

Is it safe to collect a Flow in a Fragment's onCreateView?

No. You must collect a Flow only when the View is active to prevent crashes and resource leaks. Always collect using one of the lifecycle-aware methods, such as lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { ... } }.

What are your biggest Coroutine headaches?

I hope this breakdown helps you write cleaner, faster, and more robust Android code.

I’m curious to know:

  1. Which of these anti-patterns have you found lurking in your own codebase?
  2. Do you have a different “favorite” Coroutine anti-pattern that wasn’t covered here?
  3. What is your preferred method for handling navigation after an asynchronous save operation?

Let me know in the comments below!

If you find my content useful, feel free to support my work here. Every contribution is greatly appreciated! 🙏

Comments

Popular posts from this blog

Stop Writing Massive when Statements: Master the State Pattern in Kotlin

Code Generation vs. Reflection: A Build-Time Reliability Analysis