The Big Difference Between Flows and Channels in Kotlin

Stop worrying if flows are hot or cold, and focus on good old-fashioned encapsulation instead

Sam Cooper
Better Programming

--

Photo by Etienne Girardet on Unsplash

Maybe you’ve heard Kotlin programmers say that “channels are hot, flows are cold.”

It’s a useful distinction between two ways of working with an asynchronous data stream. Flows and channels are as different as functions and objects. But that’s not the whole story, because flows themselves come in at least two very different forms. That’s where the limited hot and cold analogy starts to break down.

How can we improve the vague metaphor into something more concrete and actionable?

🔥 We call channels “hot” because they’re stateful objects. A channel is a communication mechanism that lets you receive values from other computations. As the consumer, your interaction with the channel doesn’t necessarily control when that computation starts and stops.

Think of it like the moving escalator on the subway. It’s operating before you start using it and will most likely continue after you leave.

❄️ Flows are called “cold” because they don’t hold state. When you pass a flow around in your Kotlin code, the flow isn’t holding or producing any data. That’s because a Flow object isn’t an active instance of a data stream. Instead, each time you call collect you create a new, ephemeral instance of the flow’s computation that only exists inside that function call.

If a channel is like the moving escalator in the subway station, a flow is more like the elevator. It only starts operating when you start interacting with it and stops again as soon as you leave.

Photo by Shawn Ho on Unsplash

This distinction is fine when dealing with the most basic channels and flows. But there’s more than one way to create a Flow, and if you’ve spent much time working with them, you’re probably already picking holes in my oversimplified explanation.

“What about hot flows?” you ask.

Flows: Hot or Not?

We started with the simple statement that flows are cold. But when we dig a bit deeper, it starts to look like that might not always be true. For example, the first line of documentation for Kotlin’s built-in SharedFlow proclaims that it’s “a hot flow.” It goes on to explain that:

“A shared flow is called hot because its active instance exists independently of the presence of collectors.”

“Hot flow” is a newer term used to describe flows backed by some active computation that outlives any individual consumer.

I don’t like this terminology. Calling channels hot and flows cold is useful, but adding a further distinction in the form of hot flows is confusing and arguably not necessary. I’ll explain why.

There are two reasons that we need to stop talking about flows being hot and cold. The first is that the distinction is imprecise. What exactly is it that elevates a flow from cold to hot?

We saw flows being described as hot in the SharedFlow documentation. Another example of a flow being called “hot” is the consumeAsFlow function of a channel. It’s not a SharedFlow, but labelling it as hot still seems to make sense because the channel is an active, stateful source of data that continues to produce values regardless of whether the flow is being collected.

The consumeAsFlow function isn’t the only way to turn a hot channel into a flow. We can consume the same stream of values with just a couple of lines of handcrafted flow code.

These two pieces of code are totally interchangeable: they provide equivalent, if not identical, behaviour and functionality. And yet the documentation for the flow { ... } builder is very clear that it “creates a cold flow.”

If these two flow examples do the same thing, how can one be cold and one be hot? We’ll come back to this, but the short answer is that distinguishing hot flows from cold flows isn’t very useful because there’s no clear definition for what it means.

The second reason I don’t like the hot and cold flow distinction is because it’s not actionable.

Let’s say someone hands you a shiny new flow and tells you, “careful, it’s hot!”

Photo by Becca Tapert on Unsplash

You appreciate the warning, but after a moment, you start to wonder: what can you do with that information?

Do you need to avoid collecting the flow more than once? The “hot” flow created by consumeAsFlow comes with that restriction, but plenty of other flows that call themselves hot, including the similar receiveAsFlow, can be collected as many times as you like.

What about resource management? Are there underlying hot channels or other data sources that need to be cleaned up once you’re done collecting the hot flow? Maybe, but figuring out the details isn’t going to be easy. If you used consumeAsFlow, the channel will have been closed as soon as you stopped collecting the flow. But if you used receiveAsFlow to opt out of that automatic cleanup, it’s anyone’s guess who’s responsible for cancelling the channel.

There might even be several consumers all collecting the flow at the same time. It looks like a Catch-22: if one consumer cancels the channel, it might cause problems for the other consumers; but if none cancel the channel, it becomes a resource leak and stays open forever!

Knowing how to close the resources associated with a hot flow is tricky too. Channels provide their own cleanup mechanism: call the cancel method to signal that you’re done receiving values, and trust that the producer will react appropriately and decide which things need cleaning up. But with a flow, your only interactions are to start and stop collecting it. The flow doesn’t have any functions to close its resources or shut it down, no matter how “hot” it claims to be.

Does that mean that hot flows are just making life difficult for us? Shouldn’t a hot flow have a dedicated close or cancel method to make all these problems go away?

We’ll come back to this one too, but the short answer is that flows are designed so consumers don’t have to worry about resource management. Instead, structured concurrency takes care of resources that outlive an individual flow collector. When done right, that’s entirely automatic, so labelling a flow as “hot” doesn’t — and shouldn’t — change how you use it.

Photo by Marc Szeglat on Unsplash

One of These Things Is Not Like the Other

I think it’s clear from the frequent mentions of “hot” and “cold” in the flow documentation that some distinction is worth capturing here. The question is, can we clarify the difference so it’s more precise and actionable?

To start with, let’s go back to the original distinction between hot channels and cold flows. Disregarding whether hot flows are a real thing, what is it that makes cold flows and hot channels fundamentally different?

It can be easy to assume that flows and channels are just two variations of the same basic idea of an asynchronous stream. The “hot” and “cold” distinction makes it sound like the main difference is that a channel is always running, and a flow only runs when you collect it.

Here’s a request I want to make: stop trying to think of channels and flows as related concepts.

  • ❄️ A flow is a control structure. It contains executable code, just like a suspend function. When you collect a value from a flow, you’re invoking the code inside the flow, just like executing a function’s code by calling the function.
  • 🔥 A channel is a communication mechanism. It handles messages or values and lets you pass them from one place to another. It doesn’t contain any code. When you receive from a channel, you’re simply collecting a message left by some other code.

The distinction between a flow and a channel in Kotlin is just like the foundational difference between a function and an object. You could describe objects as hot and functions as cold, too. An object has a stateful existence that continues even while you’re not interacting with it. Meanwhile, a function only holds state while that function is being invoked. It’s instantiated when you call it and goes away again when you’re done.

The distinction between a Flow and a Channel in Kotlin is as foundational as the difference between a function and an object.

This is the fundamental idea we capture when we say that channels are hot and flows are cold. Rather than just pointing out a property of the data stream or its lifecycle, we’re describing the intrinsic difference between two quite separate programming concepts.

Photo by Dana Andreea Gheorghe on Unsplash

Managing Resources

Because flows and channels are fundamentally different, they have quite different capabilities and limitations. One of the ones that will be most relevant for this discussion is how they clean up after themselves.

Because a flow behaves like a function, each invocation has a clearly defined entry and exit. This is a tenet of structured programming, and one of its benefits is to let us use try/finally inside a flow for automatic resource management. Any time a flow terminates — whether the consumer went away, the producer ran out of values, or the whole thing ran into an error — the flow unwinds its stack just like a function call and invokes any finally blocks in its exit path. You can’t forget to shut down a flow in the same way you can’t forget to exit a function invocation.

There’s no special magic to make this possible, by the way. A flow behaves like a function call because it is a function call. Consuming a flow always happens by making a single call to its collect function. It’s when that function exits for any reason that the flow terminates.

Good resource management is a form of encapsulation. When a function exposes its open resources — for example, if it returns a Closeable result — the caller needs to be aware of it so they can clean up after it. But if a function manages its own resources with try/finally, or with a related mechanism like consume or use, the caller doesn’t have to know anything about what’s happening inside the box.

From the outside, a function that includes automatic resource cleanup looks the same as a function that doesn’t use any resources. The programming language guarantees to run the cleanup code whenever the function exits for any reason, so the programmer never has to worry about it.

By their nature, flows are great at this sort of resource management and encapsulation. A flow never returns any results, Closeable or otherwise. Instead, when you collect a flow, it provides values by executing the code you pass to the collect function. The flow implementation can wrap your collector code in a try/finally block before executing it so the flow’s resources will always be closed if your code exits for any reason.

The collector’s code gets invoked when the flow calls emit. It’s exactly the same “don’t call us, we’ll call you” pattern that you’ll see in all kinds of resource-management functions, from the generic Closeable.use to the more specialized Reader.forEachLine.

Channels don’t have the same advantages when it comes to managing resources. Whereas a flow is fully consumed by a single call to its collect function, getting values from a channel consists of multiple individual calls to receive. That means the channel doesn’t have a well-defined start and end point and can’t make the same guarantees about what will happen when the consumer goes away.

All the code examples in this post are interactive and editable. Notice how the finally block doesn’t get executed when you run this code. If you try fixing it by adding the missing call to messages.cancel(), it will help you understand the changes we’ll make to the code in the next example.

Functions like consume are designed to help mitigate this problem, ensuring you don’t forget to call cancel when you stop receiving values from a channel. But with channels, remembering to call consume or cancel is always the responsibility of the consumer, not the producer.

A channel’s resource management isn’t fully encapsulated. The channel's consumers must be well-behaved participants if it wants to guarantee that its producer will shut down cleanly. When you call a function that returns a channel and then forget to consume or cancel it, any associated resources might remain open indefinitely.

Structured Concurrency

I say “might” because there’s another way those leaked resources could get closed. Channels are commonly used for communication between coroutines. We can rely on structured concurrency to prevent resource leaks when dealing with coroutines.

With a very small change, we can make the previous example use structured concurrency properly. All we need to do is stop using GlobalScope, and group our code inside a shared coroutineScope instead.

Now that all our code belongs to the same structured coroutine scope, errors in one place will cause other coroutines to be cancelled and cleaned up. That means that finally blocks in the producer coroutine will still be executed, even though the channel was never explicitly cancelled.

This is a useful safety measure. But if it feels messy, there’s a very good reason for that. The channel cancellation mechanism, and the cancellation exceptions that result from it, predate the introduction of structured concurrency and don’t always play nicely with it.

Now that structured concurrency is in the mix, there are two different ways this channel’s resources can be managed. Cancelling the channel itself is one option, and cancelling the coroutine producing the channel’s values is another. Which is the right choice? Will the result be the same, or are there subtle differences in behaviour? Should you always do both to be on the safe side?

In my article, “The Silent Killer That’s Crashing Your Coroutines,” I explained that a cancelled channel throws a CancellationException that can cause some nasty and unexpected issues. Because cancelled coroutines and cancelled channels reuse the same type of exception, the line between a cancelled channel and a cancelled coroutine can become blurred. In the worst case, cancelling a channel can cause an entire coroutine to be silently cancelled when you didn’t want it to be.

It’s worth being clear at this point that channels don’t hold any resources of their own. There’s nothing saying they need to be emptied or closed after use. A channel that’s no longer referenced can be freely garbage collected, just like any other object.

Cancelling a channel does nothing more than send a signal between the producer and the consumer. This lets the code at the other end of the channel know that it should stop sending values and clean up any resources it was using. In other words, the only reason a channel needs to be closed or cancelled is when it’s being actively used to work with other coroutines and resources. Once we have structured concurrency to manage those coroutines and resources for us, there’s no reason channels need to be cancelled at all.

Let’s take a minute to think about what a ReceiveChannel API could look like in a world where channels never need to be cancelled or closed.

  • The channel remains open forever, so calling receive either suspends or returns a value: it never throws an exception.
  • The receiveCatching function won’t be necessary because the channel has no failed or closed states. If the channel producer runs into an error, the problem can be handled by the coroutine scope it’s running in rather than being passed to the consumer.

But what do we do when we’re done using this theoretical channel? How do we stop the consumer from suspending infinitely when the producer does not intend to produce more values? Easy. We cancel the consumer coroutine. This can be done automatically in many cases due to structured concurrency.

Shared Flows

If that sounds familiar, it might be because it’s almost exactly the design behind the SharedFlow API. You can read in the docs for shared flows that:

“A call to Flow.collect on a shared flow never completes normally.”

This is because a shared flow does away with all communication between the upstream flow and the downstream collector, aside from passing the flow’s actual values. Like our hypothetical non-closable channel, a SharedFlow can never propagate errors or termination to consumers.

If the upstream flow terminates, the consumers of a shared flow will just suspend forever, waiting for a value that never comes. To compare this to the behaviour of a normal flow, try editing the example to remove the line that calls shareIn.

Even if the upstream flow crashes in the most horrible way, the shared flow remains open. Or rather, it would, if it wasn’t for structured concurrency. In reality, the failure of the upstream flow will cause an error that propagates out to the rest of its containing scope. From there, the application can decide whether to cancel the consumer coroutines.

Shared flows are like a better-encapsulated version of channels. They might have an active producer coroutine that outlives their consumers, but they hide all of its errors, resources, and cancellations from the consumer.

The upshot of this is that a SharedFlow can be treated by its consumers in exactly the same way as a regular Flow. All you need to do is call collect and handle the values one by one. That makes sense: shared flows and regular flows implement the same interface, after all. And that’s the fundamental reason we don’t need to bother calling flows “hot” or “cold.” They may have resources attached, but the responsibility for dealing with those rests squarely with the producer and its coroutine scope, never with the consumer.

Encapsulation and substitutability are what make flows in Kotlin so powerful and versatile. If I take a SharedFlow and apply a series of operators and transformations to it, is the resulting flow hotter or colder? What about taking a cold flow and launching some coroutines to monitor and augment its values? The answer is simple: it doesn’t matter. So long as I stick to structured concurrency and manage and encapsulate resources correctly, every flow is interchangeable.

When a function returns a Flow, I don’t need to know anything about where it came from to use it. When a function accepts a flow, I know I can produce it however I like, and it will work correctly. I don’t need to worry about whether the flow was described as hot or cold, and nor do you.

The Right Tool for the Job

Photo by Louis Hansel on Unsplash

Maybe, after all this talk about channels and flows, you’re still wondering which one you should use. The answer is simple: use both! Instead of thinking about channels and flows as two different ways of doing the same thing, consider them two entirely different tools for two different jobs. Channels are for communication; flows are for encapsulation and code reuse.

  • When you want to pass values from one coroutine to another, use a channel.
  • When you want to encapsulate your value-producing code so that consumers don’t have to worry about when it starts, stops or fails, use a flow.

The two tools can and should be used together, too. I even wrote a whole article on channel flows, the built-in API that combines a channel producer with a flow consumer. When you want the benefits of concurrency and encapsulation, feel free to make a channel and wrap it up in a flow! You can mix and match: read a few values from a channel, then wrap it up in a flow to delegate the remaining values and cleanup procedures to some other code.

By wrapping a channel in a flow, you make your application safer and more predictable. You decide what will happen, if anything, when the flow exits. Consumers don’t have to worry about remembering to clean up resources and handle errors, and you don’t have to worry about your coroutines being terminated by consumers calling cancel at the wrong time.

Encapsulation does have its limits, though. No matter how clean and tidy the wall looks from the outside, there will always be code that lives behind the wall. The encapsulation provided by a flow comes largely from the fact that it can be represented as a single function call, with all the structured programming guarantees that brings. But a single function call requires a single control flow. As soon as we need to deliver values between producers and consumers that don’t live inside the same function or the same coroutine, we have to step through the encapsulation boundary that a flow provides.

When multiple coroutines are consuming or producing at the same time, a channel is the communication tool they use to distribute and coordinate their work. But with proper use of flows and structured concurrency, the channel and all its coroutines can still be wrapped up and encapsulated so that the rest of the application doesn’t have to worry about them.

--

--