I Counted Every Hidden Channel Kotlin Flow Creates — Most Devs Are Wrong About Which Operators Are…

Image Generated for the Article

I Counted Every Hidden Channel Kotlin Flow Creates — Most Devs Are Wrong About Which Operators Are Free

buffer() and flowOn() are supposed to fuse into one channel when chained. I wrote code to prove it — and then found four “harmless” operators that quietly break it.

Ask any experienced Kotlin developer what happens when you chain .buffer().flowOn(Dispatchers.IO).buffer(), and you’ll usually get a confident answer: “it fuses into one channel.” That’s the folklore, repeated in talks, docs, and Stack Overflow answers.

It’s also only half true, and nobody seems to have actually checked the other half: which everyday operators quietly stop that fusion from happening.

I decided to stop trusting folklore and instrument the actual runtime. Five separate experiments later, I had a very clear, very measurable answer — and one of the results genuinely surprised me.

The Myth, and the Mechanism Behind It

Kotlin’s coroutines library really does fuse adjacent channelFlow, flowOn, buffer, and produceIn calls into a single underlying channel instead of creating one per operator. This isn’t a rumor — it’s real, deliberate behavior, implemented through an interface called FusibleFlow.

Here’s the actual check, straight from the source:

When flowOn() or buffer() is called, it checks whether the flow it’s being called on already implements FusibleFlow. If it does, instead of wrapping it in a new object, it calls .fuse() — which updates the existing object’s settings (buffer size, dispatcher) in place. No new object. No new coroutine. No new channel.

But that check only succeeds if the upstream is already a ChannelFlow. And that’s where the folklore quietly stops being true.

Building Something to Actually Measure This

I wrote two nearly identical pipelines — one where the fusible operators sit directly next to each other, and one where a completely ordinary map() sits in the middle:

Nothing about the “broken” version looks dangerous. It’s the kind of line every Android developer writes without a second thought. That’s exactly the point.

Proof 1: Counting the Wrapper Objects

Every Flow that isn’t fused wraps its upstream in a new object — like a nesting doll. I wrote a small reflection-based function that walks the private upstream-flow reference inside these objects and counts how many layers deep it goes:

Running this against both pipelines gave a clean, unambiguous result:

The fused chain is still, structurally, a single object — buffer size and dispatcher just got updated in place. The moment map() shows up, Kotlin has no choice but to build a second, separate wrapper on top of it.

Proof 2: Watching It Happen at Runtime

Object structure is one thing — but does it actually change how many coroutines exist while your code is running? I used kotlinx-coroutines-debug’s live coroutine dump to check, mid-collection:

Diagram generated for this article, built from real measured output.

The fused pipeline showed exactly 2 live coroutines: one collector, one producer. That’s the theoretical minimum — one of each.

The broken pipeline showed 3: the collector, plus two separate producer coroutines. map() didn’t just create an extra object — it created an entire extra coroutine, complete with its own internal channel and its own scheduling overhead, for a transform that does nothing more than multiply a number.

Proof 3: The Real Thread-Hop Cost

Extra coroutines are one kind of overhead. Extra thread hops are another, more directly tied to performance. I wrapped Dispatchers.IO and Dispatchers.Default in a custom dispatcher that logs every real dispatch call:

The fused pipeline made exactly 1 real dispatcher hop. The broken pipeline — same operators, same intent, just map() sitting in a different place — made 3. Three times the thread hand-offs, for code that reads almost identically.

The Twist: Not Every “Ordinary” Operator Behaves the Same Way

At this point, the obvious follow-up question is: is it just map(), or does any transform in the middle break fusion? I tested four operators that all look equally ordinary — catch(), onEach(), distinctUntilChanged(), and conflate():

Here’s what came back:

Three operators most developers would assume are “lightweight” — a side-effect hook, an equality check, an error handler — all break fusion exactly like map() does.

conflate(), meanwhile, stays completely fused. That’s not an accident: conflate() is literally implemented internally as buffer(capacity = 0, onBufferOverflow = BufferOverflow.DROP_OLDEST). It is buffer(), wearing a different name. I confirmed this held even at the coroutine level — swapping map() for conflate() in the exact same position dropped the live coroutine count straight back down from 3 to 2.

What This Actually Means for Your Code

This isn’t an argument against using map(), catch(), onEach(), or distinctUntilChanged() — they’re essential, and in most UI-layer Flow chains, one extra coroutine changes nothing anyone will notice.

It matters when you’re building something with real throughput — a high-frequency sensor pipeline, a large paginated data stream, a hot WebSocket feed — where every extra coroutine and every extra thread hop adds up. In those cases:

  • Group your fusible operators together. If you’re going to buffer() and flowOn(), do it adjacently, before or after your transforms — not sandwiched in between them.
  • Don’t assume “simple-looking” means “free.” onEach {} and distinctUntilChanged() read like they should be nearly free. They aren’t, in this specific sense.
  • Reach for conflate() over buffer(0, DROP_OLDEST) when it fits your semantics — not because it’s clearer (though it is), but because it’s a genuine drop-in replacement for buffer() at the implementation level.

The Real Takeaway

None of this changes what Flow is for. It changes what “the compiler/runtime will optimize this for me” actually covers. Fusion isn’t a blanket guarantee across your whole pipeline — it’s a narrow, specific mechanism that only fires between a small set of operators, and it silently steps aside the moment something else gets in the way.

The folklore (“buffer/flowOn fuse”) is true. The unspoken assumption most of us carry alongside it — that most operators are basically free — isn’t. The only way to know which side of that line a given operator sits on is to actually measure it, the same way we just did.

If this changed how you’ll write your next Flow pipeline, give it a clap 👏 — and if you’ve found another operator with a surprising fusion story, drop it in the comments. I’ll test it.


I Counted Every Hidden Channel Kotlin Flow Creates — Most Devs Are Wrong About Which Operators Are… was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.