Kotlin Flow's Hidden Channel Overhead: Surprising Fusion Breakers
Common operators like map() silently create extra coroutines — here's the proof.
1The Fusion Myth
Kotlin's coroutine library fuses adjacent buffer(), flowOn(), and produceIn() calls into a single channel through FusibleFlow interface. This isn't just theory — it's confirmed in the source code. However, this fusion breaks when certain 'harmless' operators are inserted between fusible calls, creating unexpected overhead. The article's author built instrumentation to measure this effect directly.
2Experimental Proof
Through reflection and coroutine debugging, the author proved that inserting a simple map() transform between buffer() and flowOn() creates: 1) An additional wrapper object in the Flow chain, 2) An extra producer coroutine at runtime, and 3) Three thread hops instead of one. This occurs despite identical functionality — the only difference being operator ordering.
3Unexpected Culprits
Testing revealed four common operators that break fusion: map(), catch(), onEach(), and distinctUntilChanged(). Surprisingly, conflate() maintains fusion because it's internally implemented as buffer(). This distinction matters because each broken fusion case creates separate channels and coroutines — invisible in code but measurable in performance.
4Runtime Impact
Coroutine debug dumps showed fused pipelines use just 2 coroutines (collector + producer), while broken chains require 3. Custom dispatcher logging proved thread hops triple from 1 to 3. These overheads compound in high-throughput scenarios like sensor pipelines or WebSocket feeds, where micro-optimizations matter most.
5The conflation Exception
conflate() behaves differently than other transforms because it's essentially buffer() with different parameters. When substituted for map() in tests, it maintained fusion and reduced coroutine count back to 2. This reveals implementation details matter more than operator appearance — what looks 'lightweight' may carry hidden costs.
6Practical Implications
The findings don't mean avoiding these operators — they're essential tools. Instead, developers should: 1) Group fusible operators together, 2) Place transforms either before or after fused blocks, not between them, and 3) Remember that 'simple-looking' operators aren't necessarily free in Flow pipelines.
7When Optimization Matters
For most UI Flows, these micro-optimizations won't be noticeable. But in high-frequency data streams (10,000+ events/second), each extra coroutine adds scheduling overhead. The article provides concrete measurement techniques to identify such bottlenecks using coroutine debugging and custom dispatchers.
8Beyond Folklore
This investigation proves that even well-documented behaviors like Flow fusion have undocumented edge cases. The methodology — combining source analysis, runtime instrumentation, and controlled experiments — provides a template for verifying other performance assumptions in coroutine-based systems.