Kotlin

What Are Kotlin Flows? Explained with Examples

What Are Kotlin Flows? Explained with Examples

Kotlin Flow is the stream type in the kotlinx.coroutines library. It emits a sequence of values over time through suspend functions, with no callbacks involved. JetBrains built it straight into Kotlin’s coroutine model. On Android you usually collect it in a repository or a ViewModel, and that’s how data from a database or a network call gets to the screen without blocking a thread.

Flow has grown along with coroutines. In JetBrains’ own Kotlin Census, coroutine adoption reached 56 percent of Kotlin developers, up from 45 percent two years earlier (JetBrains, 2020).

What Is a Kotlin Flow?

YouTube player

You write a producer block and call emit whenever a value is ready. Each emit can suspend, and values come out in order, one after another.

Flows are cold by default, so nothing runs until a collector starts listening. Flow was also designed as part of Kotlin coroutines from the start. It wasn’t added on from outside.

The Kotlin language got structured concurrency first. Flow mostly formalizes a producer-consumer pattern on top of that.

Flow became a stable, non-experimental API in kotlinx.coroutines 1.3.0, released in August 2019 (Kotlin/kotlinx.coroutines changelog). You needed Kotlin 1.3.50 or newer to compile it back then. The requirements have moved a lot since. The current major release, kotlinx.coroutines 1.11.0, raised the baseline to Kotlin 2.2.20.

Don’t mix it up with java.util.concurrent.Flow, which is a separate reactive stream type built into the JDK. They share a name and nothing else.

Roman Elizarov, JetBrains’ Project Lead for Kotlin and the original designer of Kotlin’s coroutines, pushed for this sequential, suspend-based design. Older reactive libraries leaned on callbacks, and he wanted to get away from that.

What Is the Difference Between a Cold Flow and a Hot Flow?

YouTube player

It comes down to when the work starts. A cold flow sits idle until something collects it, while a hot flow keeps running whether there are collectors attached or not.

Every time a new collector attaches to a cold flow, the producer block runs again from the beginning. So two collectors reading the same cold flow can fire the same network call or database query twice. I’ve seen this cause duplicate API requests in production more often than I’d like to admit.

Why is Kotlin becoming the new Java?

Discover Kotlin statistics: Android adoption, multiplatform growth, developer satisfaction, and the modern language evolution from JetBrains.

Explore Kotlin Data →

You build a cold flow with the flow builder. It produces only on collection and restarts for each new collector.

Hot flows stay active on their own and share one running stream between everyone collecting. If nobody is around when a value goes out, that value is gone, unless the flow is set up to replay it.

This matters most in mobile application development, where a screen rotation or a fragment recreation can spin up a fresh collector on a flow that is already mid-request.

StateFlow and SharedFlow are on the hot side. Neither one existed as stable API until kotlinx.coroutines 1.4.0 shipped in October 2020, a full year after Flow itself went stable (Kotlin/kotlinx.coroutines, 2020).

How Does a Kotlin Flow Emit and Collect Values?

YouTube player

You need a builder that produces values and a terminal operator that consumes them. Leave out either one and nothing happens.

Flow Builders

Which builder you pick depends on where the values come from.

  • flow is the general-purpose one. You write the emission logic yourself inside a suspend lambda.
  • flowOf wraps a fixed set of known values
  • asFlow turns a list or a range (or any existing iterable) into a flow
  • channelFlow is for when more than one coroutine needs to emit at once
  • callbackFlow adapts a callback-based listener

The flow builder itself is really just a lambda block that suspends and emits as it goes.

callbackFlow is there to wrap an existing API integration that was never written with coroutines in mind. Location listeners and Bluetooth scan callbacks are the usual examples.

Terminal Operators

collect is the one you’ll use most. It runs a lambda for every emitted value.

The others are more specialized. first grabs one value and then cancels the flow. single expects exactly one value and throws if it sees zero or more than one (I rarely use it outside of tests). reduce folds every value into a single accumulated result, and toList gathers everything into a List once the flow finishes.

Looking to sharpen your Kotlin skills? Data classes, extension functions, null safety, and everything else you need - including let, apply, and scope functions - is on one page in the Kotlin Cheat Sheet.

A flow on its own only describes work. The terminal operator is what turns that description into a running coroutine. Without one, nothing executes, and that’s the whole idea behind a cold stream.

What Operators Transform a Kotlin Flow?

YouTube player

Intermediate operators reshape a flow without consuming it. Each one returns a new flow and doesn’t trigger collection.

Some change the values themselves. Others flatten nested flows or deal with timing on noisy streams. The table has a few of the common ones.

OperatorCategoryWhat It Does
mapTransformConverts each emitted value
filterTransformDrops values that fail a condition
flatMapLatestFlatteningCancels the previous inner flow on a new emission
debounceTimingWaits for a pause before emitting

map does most of the day-to-day work. It turns a raw stream of network responses into Kotlin data classes the rest of the app can actually use.

flatMapLatest is the one you want in search boxes and autocomplete fields. A new keystroke cancels whatever RESTful API call is still in flight, so you don’t get two responses racing each other.

For search, it’s usually paired with debounce, which waits for a pause in typing before it fires. distinctUntilChanged is often in there too. It skips a value if it’s identical to the one right before it.

How Does flowOn Control Threading in a Kotlin Flow?

YouTube player

flowOn changes the context of everything above it in the chain. It has zero effect on anything below it.

Say you put a database read inside a flow builder and call flowOn(Dispatchers.IO) on it. The read happens off the main thread. Everything downstream keeps running wherever the collector’s own coroutine already lives.

On Android, network and database work normally goes on Dispatchers.IO inside the builder, and disk reads go there too. Collection happens on Dispatchers.Main, because UI updates have to stay on the main thread.

Kotlin enforces this through context preservation. A flow builder isn’t allowed to emit on a different dispatcher than the one it was collected on, unless flowOn sits between the two.

If you switch dispatchers inside the builder with withContext instead of using flowOn, the flow throws an exception the moment it tries to emit from the wrong context.

How Does a Kotlin Flow Handle Exceptions?

YouTube player

Flow follows a rule called exception transparency. In practice, you catch upstream failures with a flow operator, not with a plain try/catch wrapped around collect.

The catch operator intercepts an upstream exception. From there the flow can emit a fallback value or just stop. For another attempt, retry re-runs the upstream flow from scratch a fixed number of times after a failure. retryWhen does the same, except you decide with your own logic, based on the exception and the attempt count.

One thing that trips people up is that catch only sees exceptions thrown above it in the chain. Anything thrown inside the collect block itself is out of its reach and needs separate handling.

A try/catch around collect doesn’t cover everything either. An exception from a separate coroutine inside channelFlow or callbackFlow is completely out of its reach.

How Do Buffering and Cancellation Affect a Flow?

Slow collectors and cancelled scopes need different handling, so they’re covered separately here.

Buffering and Backpressure

OperatorBehaviorBest For
bufferQueues every value, collector catches up laterKeeping full history
conflateDrops older values, keeps only the latestFast-changing state
collectLatestCancels in-progress work on a new emissionSearch-as-you-type screens

By default, buffer queues 64 items before it applies backpressure. kotlinx.coroutines set that number in the same 1.3.0 release that made Flow stable (Kotlin/kotlinx.coroutines changelog, 2019).

buffer uses extra memory so it can keep every value. The other two drop things. conflate throws away stale values, and collectLatest abandons unfinished work when newer input shows up.

Cancellation Behavior

A flow has no cancel button of its own. It stops when the CoroutineScope that launched its collection gets cancelled.

When the scope goes, the flow goes with it, even halfway through an emission.

If you collect inside a coroutine scope tied to a screen’s lifecycle, collection stops automatically when that scope ends. You don’t have to unsubscribe manually, which is one of my favorite things about Flow compared to managing subscriptions by hand.

Cancellation travels through every operator in the chain. The upstream builder stops emitting right away, and collectors never receive a half-finished emission once cancellation lands.

What Is the Difference Between StateFlow and SharedFlow?

YouTube player

The quickest way to tell them apart is that a StateFlow can never be empty.

The real difference is replay. StateFlow always replays exactly one value, its current one. With SharedFlow you choose the replay count, and zero is allowed.

A guaranteed value means a new StateFlow collector always gets something. The downside is that you need an initial value when you create it, and sometimes you don’t have one yet. I usually end up adding a Loading state or a null placeholder just to satisfy it.

SharedFlow is a cleaner fit for one-time events, because nothing stays around after it fires. But you have to configure replay yourself. If you leave it at the default, a late subscriber misses everything that was already sent.

Both are built on top of Channel, the lower-level coroutine primitive Kotlin used before either type existed. The older ConflatedBroadcastChannel is now deprecated in their favor.

MutableSharedFlow ships with replay and extraBufferCapacity both set to 0 by default, and onBufferOverflow set to BufferOverflow.SUSPEND, so an unconfigured SharedFlow suspends the emitter the moment a slow collector falls behind (kotlinx.coroutines API reference).

When Should You Use Kotlin Flow Instead of LiveData or RxJava?

YouTube player
AspectFlowLiveDataRxJava
Platform scopeKotlin MultiplatformAndroid onlyJVM, ported to other platforms
Lifecycle awarenessManual, via repeatOnLifecycleBuilt inManual, via disposables
Operator countSmaller, coroutine-nativeMinimal (map, switchMap)Large, decades of operators
Learning curveModerate, needs coroutinesLowSteep

LiveData came out as part of Android Architecture Components, announced at Google I/O 2017. That’s why it only ever worked inside Android and never on plain Kotlin code running on a server or an iOS target.

RxJava has decades of operators that Flow still doesn’t have. The cost is a much bigger API to learn, plus disposables you manage by hand, where Flow lets structured concurrency do the cleanup.

Which one fits depends on the app’s software architecture more than any single row in that table. Google’s own migration guidance leans toward Flow for anything past a single observed value. It treats RxJava as something to keep only where a codebase already depends on it heavily.

When Does Kotlin Flow Not Apply?

Flow isn’t the right tool everywhere.

If a network call returns once, use a plain suspend function. Wrapping one emission in a flow adds nothing.

Screens still built with the older View system and XML data binding usually read more naturally from LiveData, since the data binding library was written around it first.

Very high-frequency sources are another case. A sensor or animation firing hundreds of times a second pays a real, measurable cost per emission that a raw callback wouldn’t.

And when exactly one producer talks to exactly one consumer, Channel fits the job better than a flow built for broadcast.

None of this makes Flow a bad choice in general. It has limits, like any other tool.

How Do You Use Kotlin Flow in Android Architecture?

YouTube player

In a typical app, this is the path a database row takes to reach the screen.

  1. Room returns the query result as a Flow from the DAO
  2. The repository exposes that Flow upward, usually untouched or lightly mapped
  3. The ViewModel turns it into a StateFlow with stateIn, adding a starting value and a sharing strategy
  4. The UI layer collects that StateFlow, tied to what is currently visible on screen

Repository Layer

A DAO method annotated with @Query can return Flow directly. Room re-emits a fresh result set every time one of the underlying tables changes.

Room added this Flow return type in version 2.2.0-alpha02, released on August 7, 2019 (AndroidX Room release notes, Google).

The repository sits right above the DAO. It’s usually wired in through dependency injection rather than constructed by hand, and most of the time it just forwards that Flow along.

Sometimes it does a bit more, like mapping raw entities into domain models or merging 2 or more Flow sources with combine. Catching and translating database exceptions before they reach the ViewModel also tends to happen here.

ViewModel Layer

stateIn takes the cold flow from the repository and turns it into a hot StateFlow, so the UI can safely collect it more than once. Most people pass SharingStarted.WhileSubscribed as the sharing strategy. It keeps the upstream flow alive for a short grace period after the last collector leaves.

This is the MVVM pattern in its plainest form. The ViewModel holds state, and the UI just watches it.

Get the setup wrong and you’ll see it. A bad starting value makes the UI flicker on an empty state. With no grace period, the whole chain tears down and restarts from Room upward every time the screen rotates.

Compose Layer

collectAsStateWithLifecycle needs the lifecycle-runtime-compose artifact, version 2.6.0-alpha01 or newer, added to the build file before it works (Android Developers, Google).

It pauses collection automatically once the screen drops below STARTED. Plain collectAsState doesn’t do that on its own.

collectAsState does work outside Android, on any Compose Multiplatform target. collectAsStateWithLifecycle is Android-only, and it’s the one Google recommends for app screens.

How Do You Test Kotlin Flows?

Flow tests almost always run inside runTest. It’s the coroutine test builder from kotlinx-coroutines-test, and it fast-forwards virtual time and surfaces uncaught exceptions from child coroutines.

Without it, a delay call inside a flow chain really does wait. A 30-second retry backoff turns into a genuinely slow unit testing run.

The most common failure is a test that hangs. Usually a hot flow, often a SharedFlow, never completes and nothing ever cancels the collecting coroutine.

Sometimes the SharedFlow has no replay and gets collected too late to catch its first value. Or a StateFlow keeps emitting after the assertions expected it to be done. The one I hit most is a collector launched in the test’s own scope instead of backgroundScope, so it never gets torn down.

Turbine for Flow Assertions

Turbine is a small testing library from Cash App for asserting on flow emissions. You don’t have to write the collector plumbing yourself.

ApproachStyleBest For
Plain collect in a testManual list building, manual comparisonVery simple, single-value flows
Turbine’s test blockawaitItem, awaitComplete, awaitErrorMulti-value flows, ordering, completion

The current stable release is 1.2.1, and Cash App maintains it in the open on GitHub (cashapp/turbine).

Inside a test block, awaitItem suspends until the next value shows up. If an event goes unconsumed (an item or a completion that nobody called awaitItem or awaitComplete on), the test fails outright.

FAQ on What Are Kotlin Flows

What Is the Difference Between a Flow and a Sequence in Kotlin?

Sequences run synchronously and block the calling thread while they produce values. Kotlin Flow runs inside coroutines, so it can suspend and emit values asynchronously. Use a Sequence for in-memory collections and Flow for anything asynchronous or long-running.

Is Kotlin Flow Part of the Kotlin Standard Library or a Separate Library?

It’s separate. Flow ships inside kotlinx.coroutines, a library JetBrains maintains alongside the Kotlin standard library, and a project adds it as its own Gradle dependency. Sequence and Iterable live in the standard library and need no extra dependency.

What Role Does a Suspend Function Play Inside a Flow?

It’s what lets a flow builder pause without blocking a thread while it waits on a network call or a database read. Every terminal operator runs through this same suspend function mechanism, which is why a flow needs a CoroutineScope to run.

Can You Convert a Flow Back to LiveData, or the Reverse?

Yes, both ways. LiveData has an asFlow extension that wraps it as a cold flow, and a Flow can call asLiveData to go the other direction. Both come from the lifecycle-livedata-ktx artifact, and neither conversion changes how the original behaves.

What Common Mistakes Happen When Collecting a Flow?

Collecting a cold flow more than once without sharing it re-runs the producer every time, which duplicates network calls. Forgetting flowOn leaves work on the wrong dispatcher. And a collector launched outside repeatOnLifecycle keeps running after the screen disappears.

Do You Need Jetpack Compose to Use Kotlin Flow?

No. Flow works in any Kotlin codebase, plain Android Views and command-line scripts included, with zero dependency on Compose. Compose only adds collectAsStateWithLifecycle as a convenient way to turn a flow into UI state inside a composable function.

What Should You Learn First in Kotlin Flow?

Start with terminal operators and cold behavior, before the rest of the operator chain. collect and first decide whether a builder runs at all, and buffering, threading, and testing all build on that.

After that, learn dispatcher control with flowOn along with buffering. StateFlow, SharedFlow, and wiring them into a ViewModel come last. Going in order matters more than going fast.

A lot of people learn the whole operator catalog before they understand dispatchers, and it usually costs them a debugging session later. A stalled collector or a duplicated network call usually traces back to a missed terminal operator or a cold stream collected twice.

The ViewModel part pays off most on a multiplatform target, where the same StateFlow reaches an Android screen and an iOS screen without a second implementation. Kotlin Multiplatform is the logical next stop once this wiring feels solid.

Bogdan Sandu

Stay sharp. Ship better code.

Every week: one curated article, one tool worth knowing, one tip you can use tomorrow. No noise, no padding.