Stop the Lag: 5 Kotlin Flow Mistakes Killing Your App's Performance
From memory leaks to API spamming—how to master StateFlow architecture for a buttery-smooth Android experience.
Kotlin Flow is a game-changer for reactive programming in Android, but it’s a double-edged sword. If you treat it like a simple list or ignore the underlying coroutine mechanics, you’ll find your app stuttering, dropping frames, or leaking resources.
If your UI feels “heavy” or your background tasks are consuming unnecessary CPU, you might be falling into these common Flow traps.
The “Always-On” Stream (SharingStarted Mistake)
A common pitfall is converting a cold flow to a StateFlow using SharingStarted.Eagerly or Lazily.
The Risk: These strategies can cause unnecessary upstream work or repeated restarts when the UI lifecycle changes. Eagerly keeps the stream active even when the app is in the background, while Lazily might trigger expensive re-fetches every time the UI resubscribes.
The Fix: WhileSubscribed(5000)
By using WhileSubscribed(5000), you tell the Flow: “If no one is watching for 5 seconds, stop the upstream work.”
// ✅ BEST PRACTICE: Handles rotations without restarting the stream
val uiState = repository.streamData()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = Loading
)This 5-second buffer handles configuration changes (like screen rotation) perfectly. In data-heavy apps, this noticeably reduces background CPU usage and saves precious battery life.
Fragmented State (The Race Condition Trap)
Observing User, Cart, and PromoDeals in separate StateFlows is a recipe for a "jittery" UI. If multiple flows update simultaneously, your UI may trigger redundant recompositions, leading to flickers or race conditions.
The Fix: The combine Strategy
Merge your data streams into a single UiState object. This ensures the UI only ever receives a synchronized snapshot of the screen, matching Google’s recommended UI State Hoisting patterns.
// ✅ BETTER: One source of truth for the entire screen
val uiState = combine(userFlow, cartFlow, dealsFlow) { user, cart, deals ->
DashboardState(user, cart, deals)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Loading)API Spamming (The Search Input Mistake)
Triggering a network call on every single keystroke in a search bar is inefficient for both the device and the server. It can lead to overlapping responses and an inconsistent UI.
The Fix: debounce(300)
Adding a small delay ensures the API call only fires after the user pauses typing. This dramatically reduces unnecessary network traffic and improves the perceived snappiness of the search experience.
// ✅ BETTER: Waits for a 300ms pause before hitting the API
searchQuery
.debounce(300)
.distinctUntilChanged() // Don't search if the query is the same as last time
.onEach { query -> repository.search(query) }
.launchIn(viewModelScope)Heavy Lifting on the Main Thread (flowOn)
By default, a Flow collects on the thread that calls collect. Performing heavy JSON parsing or image processing in a map operator can block the Main Thread, leading to "Application Not Responding" (ANR) errors.
The Fix: Position flowOn Correctly
Use .flowOn(Dispatchers.Default) to move heavy work to a background thread.
Critical Note:
flowOnonly affects operators above it in the chain.
flow
.map { heavyWork(it) } // 👈 This runs on Default
.flowOn(Dispatchers.Default)
.collect { updateUI(it) } // 👈 This runs on the Collector's thread (Main)Improper Handling of Fast Emitters
When your data source produces updates faster than your UI can draw them, you hit “backpressure.” While buffer() can help by queuing items, it's a double-edged sword: it consumes memory and can lead to OutOfMemory (OOM) errors if the consumer is permanently slower than the producer.
The Comparison: Choosing the Right Strategy
The Nuanced Fix for UI: Instead of buffering and wasting memory, use conflate() to ensure your UI only renders the most recent data point.
// ✅ SAFER FOR UI: Drops intermediate states to keep the app responsive
dataStream
.conflate()
.collect { data -> render(data) }Frequently Asked Questions (FAQs)
Why specifically 5000ms for WhileSubscribed?
This is the standard for Android to bridge the gap during an Activity recreation (like a screen rotation). It prevents the “flicker” of a loading state that would happen if the flow stopped and restarted instantly.
Does combine slow down the app if I have many flows?
The overhead of combine is minimal compared to the cost of multiple UI observers triggering independent layout passes. Centralizing state is almost always a performance win in Compose.
Is debounce better than sample?
debounce waits for a pause (perfect for typing). sample emits the latest value at a fixed interval (perfect for high-frequency sensor data like a compass or GPS).
Testing Your Fixes
To ensure your performance optimizations work, use TestDispatcher to verify that your flows are executing on the correct threads and that debounce is effectively skipping unnecessary calls.
@Test
fun `search query should debounce`() = runTest {
val results = mutableListOf<String>()
val query = MutableSharedFlow<String>()
backgroundScope.launch {
query.debounce(300).collect { results.add(it) }
}
query.emit("A")
delay(100)
query.emit("AB")
delay(400) // Trigger debounce
assertEquals(1, results.size)
assertEquals("AB", results[0])
}Let’s Discuss!
- What’s the toughest thing about Flow for you? Is it the testing or the operators?
- Do you stick to
StateFlow, or are you still usingLiveData? - Have you ever accidentally created a memory leak with
SharingStarted.Eagerly?
Tell me in the comments below! 👇
📘 Master Your Next Technical Interview
Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend “Mastering Data Structures & Algorithms in Java”. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.
- E-book (Best Value! 🚀): $1.99 on Google Play
- Kindle Edition: $3.49 on Amazon
- Also available in Paperback & Hardcover.


Comments
Post a Comment