Imagine you’ve just been hired to build Funky Zoo, the world’s least responsibly managed zoo app.

The requirements seem reasonable:

  • Fetch what the capybara is having for lunch.
  • Continuously track three penguins who have somehow acquired roller skates.
  • Update the UI whenever Gerald the giraffe’s mood changes.
  • Broadcast an emergency alert whenever a lemur steals a member of staff’s keys.

Congratulations. You now need Kotlin coroutines and Flow.

Developers often frame the question as:

“Should I use a coroutine or a Flow?”

But that isn’t quite the right comparison.

Coroutines are Kotlin’s machinery for asynchronous and concurrent work. Flow is an asynchronous stream abstraction built on top of coroutines. A more useful everyday question is usually:

“Should this API return one result from a suspend function, or multiple results over time as a Flow?”

That distinction will save you from a surprising amount of unnecessary architecture — and possibly from Gerald.

Kotlin describes coroutines as lightweight, suspendable computations. They run on threads, but unlike blocking code they can suspend and free that thread to do something else. Kotlin Flow then uses coroutines to asynchronously produce and consume sequences of values.


The 20-second version

You need… Usually reach for…
One asynchronous result suspend fun
Several independent operations at once coroutineScope, async, launch
Values changing over time Flow<T>
Observable current state StateFlow<T>
Events broadcast to several subscribers SharedFlow<T>

That’s most of the article.

Unfortunately, our penguins are now travelling at approximately 14 km/h, so we should probably understand the details.


Coroutines: doing asynchronous work without callback soup

A coroutine lets code suspend and resume.

Consider our first Funky Zoo requirement:

Find out what the capybara is eating today.

There is one request and, assuming the capybara cooperates, one answer.

That is exactly the kind of job a suspending function is good at.

The function starts, waits asynchronously if necessary, obtains its result and returns.

From a ViewModel:

The code reads almost like synchronous code, but the coroutine can suspend while waiting rather than blocking the thread.

An important suspend misconception

Adding suspend does not magically put a function on a background thread.

This:

is still capable of blocking whichever thread calls it.

Android recommends making suspending APIs main-safe. If your function performs genuinely blocking I/O or expensive CPU work, the layer doing that work should move it to an appropriate dispatcher.

For example:

The caller shouldn’t need to know that your 2004-era hippo database takes four seconds and makes strange clicking noises.


Coroutines are also about structure

Coroutines aren’t merely a prettier Thread.

Kotlin encourages structured concurrency: related asynchronous tasks belong to a scope, with defined parent-child relationships.

Suppose preparing the zoo dashboard requires both the panda status and flamingo count:

Both requests can run concurrently.

More importantly, they belong to the surrounding scope.

If the operation is cancelled, its children are cancelled too. The parent also waits for its children before completing. That’s a major part of what makes coroutine-based concurrency easier to reason about than firing miscellaneous tasks into the void and hoping for the best.

This is also why casually reaching for GlobalScope is usually a bad idea.

A rogue unscoped coroutine is essentially the software equivalent of opening the orangutan enclosure and saying:

“He’ll probably come back.”


Enter Flow: when one answer isn’t enough

Now consider the penguins.

We aren’t asking:

Where is Penguin Dave?

We’re asking:

Where is Penguin Dave now… and now… and now… and why is he in the gift shop?

The value changes over time.

That is where Flow<T> becomes useful.

Then somewhere else:

Instead of returning one PenguinLocation, our function produces potentially many locations.

Kotlin’s official definition is essentially this: a Flow represents a sequential stream of values that can be produced asynchronously. A suspending function typically gives you one result; a Flow can give you multiple results over time.


Cold Flow: the zoo tour doesn’t start until somebody turns up

A normal flow { } is cold.

That means creating it doesn’t start anything.

Nothing happens yet.

Only this starts it:

Even more importantly, another collector normally starts another execution of the cold Flow.

Conceptually, both collectors get their own run of the producer.

Kotlin’s documentation describes cold flows as lazy: each collection triggers the upstream Flow again.

This matters enormously when that upstream operation is expensive.

If five collectors each collect a cold Flow that opens a websocket, congratulations: you may now have five websockets.

Sometimes that’s exactly what you want.

Sometimes your backend engineer is walking towards your desk.


Flow operators: the fun bit

One major advantage of Flow is that you can construct pipelines describing what should happen as values move through the system.

Suppose our zoo continuously reports animals:

But the Funky Zoo app has a dedicated Suspicious Animal Surveillance Screen.

Then:

Perhaps:

Nobody is surprised.

Flow provides operators such as map, filter, combine, debounce, distinctUntilChanged, flatMapLatest and many more. Intermediate operators themselves remain lazy: the pipeline starts doing work when it is collected.


A genuinely useful example: reactive search

Imagine a zoo-search screen.

The user starts typing:

You probably don’t want five network requests.

A Flow pipeline makes the desired behaviour remarkably clear:

debounce() waits for the frantic typing to settle.

distinctUntilChanged() avoids repeating identical searches.

And flatMapLatest() switches to the newest search and cancels collection from the previous one.

So when the user changes:

to:

the application can stop caring about the now-obsolete panda search.

This is a great example of something that becomes awkward when manually expressed as individual launched jobs but naturally fits a stream.


Combining Flows: because Gerald’s mood depends on everything

Flows become particularly useful when application state depends on several changing inputs.

For example:

Whenever either upstream Flow changes, the result can be recalculated.

This pattern is extremely useful for real application state:

Or, in Funky Zoo:


StateFlow: “what is true right now?”

A plain Flow represents values happening through time.

But UI code frequently wants something slightly more specific:

What is the current state right now, and tell me whenever it changes?

That’s what StateFlow is designed for.

Android describes StateFlow as a state-holder observable Flow. It always has a current value, and new collectors immediately receive the latest state.

For example:

Then:

Updating it:

Your UI can observe uiState and always have a meaningful representation of the current screen.

That’s why StateFlow fits ViewModels particularly well.

It’s not:

“Gerald became suspicious at 14:03:21.”

It’s:

“Gerald is currently suspicious.”

That difference — event versus state — is important.


SharedFlow: the zoo tannoy system

SharedFlow is another hot Flow.

Unlike a cold Flow, its producer doesn’t restart independently for every collector. The same stream can be shared among multiple subscribers.

Imagine the zoo tannoy:

Something unfortunate happens:

Several parts of the application can react to that same event.

SharedFlow is configurable: you can control how many previous values it replays and what happens when its buffer fills. Kotlin also provides shareIn() to turn an existing cold Flow into a shared hot Flow.

There is an important conceptual difference from StateFlow.

StateFlow:
“Gerald’s current mood is furious.”

SharedFlow event:
“Gerald has just kicked over a wheelbarrow.”

If somebody opens the app three hours later, they probably need Gerald’s current mood.

They don’t necessarily need every historical wheelbarrow incident replayed.


Cold Flow vs StateFlow vs SharedFlow

Think of them like this:

Cold Flow is a guided zoo tour.
The tour begins when somebody signs up. Another group signing up gets another tour.

StateFlow is the big electronic information board.
Whenever you look at it, you immediately see the latest information.

SharedFlow is the tannoy.
Everybody currently listening can hear the same announcement.

And the tannoy is once again saying:

“Would the person whose Toyota is being driven by a lemur please report to reception?”


Flow and threading: understand flowOn

Flow doesn’t mean “background thread.”

By default, Flow preserves coroutine context.

If part of an upstream pipeline genuinely needs a different dispatcher, flowOn() can change the context of the upstream operations:

Importantly, flowOn() doesn’t simply move the entire downstream pipeline and collector onto that dispatcher. Kotlin deliberately makes Flow context-preserving.

That distinction becomes important once your Flow pipeline grows beyond tutorial-sized examples.


What if values arrive faster than you can process them?

Suppose a camera emits meerkat positions constantly:

But your collector takes 500 ms to process each frame.

Flow gives you several strategies.

With buffer(), production and consumption can overlap.

With conflate(), intermediate values can be skipped when only the latest value matters.

With collectLatest(), processing the previous value is cancelled when a newer value arrives.

These are different behaviours, and choosing between them depends on what the values mean.

If you’re rendering the current location of a meerkat, skipping obsolete intermediate positions may be perfectly reasonable.

If you’re processing:

you probably shouldn’t say:

“We had some backpressure so we dropped a few of those.”


The architectural rule that gets you surprisingly far

Google’s current Android coroutine guidance gives a particularly clean recommendation for data and business layers:

Expose suspending functions for one-shot operations and Flow for data that changes over time.

That produces APIs that are very easy to understand:

Just reading the signatures tells you a lot.

fetchAnimal():

Give me an animal.

observeAnimals():

Keep telling me what the animal list looks like.

That is much clearer than wrapping absolutely everything in Flow because Flow appears more technologically sophisticated.


Please don’t return Flow just because you can

This is perfectly legal:

But if this API inherently performs one request and returns one value, ask what Flow is actually buying you.

Often:

is clearer.

Unnecessary use of Flow for one-shot operations adds stream machinery where a straightforward suspending API could communicate the contract much better.

That isn’t an absolute prohibition.

A Flow which happens to emit one value can still make sense if laziness, cancellation, composition with another Flow pipeline or a particular API contract makes the stream abstraction useful.

The important thing is meaning.

Choose the type that describes the behaviour of the data.


Likewise, don’t turn everything into a coroutine

The reverse mistake is manually rebuilding reactive streams from launched jobs.

If something fundamentally represents:

“Give me every new version of this value until I stop listening”

then repeatedly launching coroutines, maintaining callbacks and cancelling old jobs yourself is usually a clue that you should consider Flow.

Flow already gives you cancellation, transformation, composition and stream semantics on top of the coroutine ecosystem.


A realistic ViewModel

By the time Funky Zoo reaches production, we might have something like:

Notice the different jobs.

feedCapybara() is an action.

It happens once.

So the repository exposes a suspend function.

The list of animals can change.

So the repository exposes a Flow.

The screen needs a continuously available current representation of its UI.

So the ViewModel exposes a StateFlow.

Different tools, working together.

Not Flow versus coroutines.

Flow powered by coroutines.


On Android, collect with the lifecycle in mind

A hot Flow can continue existing independently of a particular screen, so Android UI collection needs to respect lifecycle.

With Jetpack Compose, a common approach is:

Android specifically provides lifecycle-aware APIs for this purpose rather than expecting screens to manually start permanent collectors.

The objective is simple:

When the user leaves the giraffe screen, the application shouldn’t continue furiously updating giraffe UI that nobody can see.

Gerald will cope.


So which one should I use?

A useful mental model is:

If you’re asking a question, a suspending function often fits.

If you’re subscribing to an answer that can change, Flow often fits.

If you’re representing the latest state, StateFlow often fits.

If you’re broadcasting occurrences to multiple listeners, SharedFlow may fit.

And if you’re doing several asynchronous pieces of work together, you’re back in coroutine territory:


Final takeaway

The biggest mistake is thinking you have to choose between Flow and coroutines.

You don’t.

Flow exists inside Kotlin’s coroutine ecosystem.

Coroutines give you structured asynchronous execution, suspension, cancellation and concurrency.

Suspending functions are an excellent API for operations that eventually produce one answer.

Flow models asynchronous values arriving through time.

StateFlow models observable current state.

SharedFlow lets multiple subscribers share a stream of events or values.

Once you think in terms of one value versus changing values, state versus events, and execution versus data, the architecture tends to become much less mysterious.

So remember:

Appendix: Kotlin Coroutine & Flow Keywords

A quick cheat sheet for the Kotlin terms used throughout this article.

Keyword / Type What it means
suspend Marks a function that can pause without blocking the thread and later resume. Usually used for async work that returns a single result.
Coroutine A lightweight unit of asynchronous work that can suspend and resume.
CoroutineScope Defines the lifetime and context of a group of coroutines. If the scope is cancelled, its child coroutines are cancelled too.
launch Starts a coroutine when you don’t need a result back directly. Returns a Job.
async Starts concurrent work that will eventually produce a result. Returns a Deferred<T>.
await() Waits asynchronously for the result of a Deferred<T>.
coroutineScope Creates a scope for related coroutine work and waits for all its child coroutines to finish.
Job Represents a coroutine’s lifecycle. It can be cancelled, completed or queried for its status.
Deferred<T> A Job that will eventually produce a value of type T. Usually created with async.
Dispatcher Controls where coroutine work executes, such as the main/UI thread or a background thread pool.
Dispatchers.Main Normally used for UI work.
Dispatchers.IO Intended for blocking I/O work such as file access, database calls or older blocking APIs.
withContext(...) Temporarily runs part of a coroutine using a different coroutine context or dispatcher.
Flow<T> An asynchronous stream that can emit multiple values over time.
flow { ... } Creates a cold Flow using a builder block.
emit(value) Sends a value downstream from inside a Flow producer.
collect { ... } Starts consuming a Flow and handles each emitted value.
map Transforms each value in a Flow into another value.
filter Allows only values matching a condition to continue downstream.
combine Combines the latest values from two or more Flows into a new value.
debounce Waits for values to stop arriving rapidly before passing the latest one downstream. Handy for search boxes.
distinctUntilChanged Suppresses consecutive duplicate values.
flatMapLatest Switches to a new Flow when a newer value arrives and stops collecting the previous one.
flowOn Changes the coroutine context used by the upstream part of a Flow.
buffer Lets the producer get ahead of a slower collector by temporarily storing values.
conflate Drops intermediate values when the consumer is slower and only the newest value matters.
collectLatest Cancels processing of the previous value when a newer value arrives.
StateFlow<T> A hot Flow that always contains a current value and emits updates when that state changes.
MutableStateFlow<T> A writable StateFlow. Usually kept private inside a ViewModel or similar class.
asStateFlow() Exposes a mutable StateFlow as read-only so outside code cannot modify it directly.
SharedFlow<T> A hot Flow that can broadcast values to multiple collectors.
MutableSharedFlow<T> The writable version of SharedFlow, allowing values to be emitted into it.
asSharedFlow() Exposes a mutable SharedFlow as read-only.
stateIn Converts another Flow into a StateFlow within a given coroutine scope.
shareIn Converts a cold Flow into a shared hot Flow.
viewModelScope Android’s coroutine scope tied to the lifetime of a ViewModel. Its coroutines are cancelled when the ViewModel is cleared.
currentCoroutineContext() Gives access to the context of the currently running coroutine, including whether it has been cancelled.

Tiny translation guide

For developers coming from other languages:

*These comparisons are deliberately approximate. The concurrency models and lifecycle rules are not identical.

And, most importantly:

Loading

Last modified: August 23, 2026

Author

Comments

Write a Reply or Comment

Your email address will not be published.