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
suspendfunction, or multiple results over time as aFlow?”
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.
|
1 2 3 |
suspend fun getCapybaraLunch(): Lunch { return zooApi.getTodaysLunch(animal = "capybara") } |
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:
|
1 2 3 4 |
viewModelScope.launch { val lunch = repository.getCapybaraLunch() println("Capybara lunch: ${lunch.name}") } |
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:
|
1 2 3 |
suspend fun performTerribleBlockingOperation() { // blocking work } |
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:
|
1 2 3 4 |
suspend fun readHippoMedicalRecords(): HippoRecords = withContext(ioDispatcher) { legacyBlockingDatabase.readHippoRecords() } |
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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
suspend fun loadZooDashboard(): ZooDashboard = coroutineScope { val panda = async { zooApi.getPandaStatus() } val flamingos = async { zooApi.countFlamingos() } ZooDashboard( panda = panda.await(), flamingoCount = flamingos.await() ) } |
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.
|
1 2 3 4 5 6 |
fun observePenguinLocation(): Flow<PenguinLocation> = flow { while (currentCoroutineContext().isActive) { emit(locationTracker.currentLocation()) delay(1_000) } } |
Then somewhere else:
|
1 2 3 |
observePenguinLocation().collect { location -> println("Penguin currently at: $location") } |
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.
|
1 2 3 4 5 6 |
val dancingPenguins = flow { println("Starting penguin disco camera") emit("Penguin doing the Macarena") emit("Penguin regretting the Macarena") } |
Nothing happens yet.
Only this starts it:
|
1 2 3 |
dancingPenguins.collect { move -> println(move) } |
Even more importantly, another collector normally starts another execution of the cold Flow.
|
1 2 |
dancingPenguins.collect { println("Keeper: $it") } dancingPenguins.collect { println("Security: $it") } |
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:
|
1 |
val animals: Flow<Animal> |
But the Funky Zoo app has a dedicated Suspicious Animal Surveillance Screen.
|
1 2 3 4 5 6 |
val suspiciousAnimals = animals .filter { it.isBehavingSuspiciously } .map { animal -> "${animal.name} requires investigation" } |
Then:
|
1 2 3 |
suspiciousAnimals.collect { println(it) } |
Perhaps:
|
1 2 3 |
Gary the Goose requires investigation Gary the Goose requires investigation Gary the Goose requires investigation |
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:
|
1 2 3 4 5 |
p pa pan pand panda |
You probably don’t want five network requests.
A Flow pipeline makes the desired behaviour remarkably clear:
|
1 2 3 4 5 6 7 |
val searchResults = searchQuery .debounce(300) .distinctUntilChanged() .flatMapLatest { query -> repository.searchAnimals(query) } |
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:
|
1 |
panda |
to:
|
1 |
panda wearing hat |
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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
val enclosureWarning = combine( giraffeMood, visitorCount ) { mood, visitors -> when { mood == Mood.FURIOUS && visitors > 50 -> Warning.CLOSE_ENCLOSURE mood == Mood.GRUMPY -> Warning.KEEP_DISTANCE else -> Warning.ALL_FINE } } |
Whenever either upstream Flow changes, the result can be recalculated.
This pattern is extremely useful for real application state:
|
1 2 3 4 5 6 7 8 9 |
database data + user preferences + network status + current filters ↓ UI state |
Or, in Funky Zoo:
|
1 2 3 4 5 6 7 |
giraffe mood + visitor density + availability of carrots ↓ likelihood of catastrophe |
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:
|
1 2 3 4 5 |
data class GiraffeUiState( val name: String = "Gerald", val mood: Mood = Mood.CALM, val neckAngle: Float = 0f ) |
Then:
|
1 2 3 4 5 |
private val _uiState = MutableStateFlow(GiraffeUiState()) val uiState: StateFlow<GiraffeUiState> = _uiState.asStateFlow() |
Updating it:
|
1 2 3 4 5 |
_uiState.update { current -> current.copy( mood = Mood.SUSPICIOUS ) } |
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:
|
1 2 3 4 5 |
private val _announcements = MutableSharedFlow<ZooAnnouncement>() val announcements = _announcements.asSharedFlow() |
Something unfortunate happens:
|
1 2 3 4 5 |
_announcements.emit( ZooAnnouncement( "A lemur has stolen the manager's BMW keys." ) ) |
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:
|
1 2 3 4 5 |
val ancientZooRecords = flow { emit( legacyDatabase.readEverything() ) }.flowOn(ioDispatcher) |
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:
|
1 2 3 4 5 6 7 |
Meerkat left Meerkat right Meerkat left Meerkat left Meerkat on Steve Meerkat under desk ... |
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:
|
1 |
MEDICATION ADMINISTERED TO RHINO |
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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
interface ZooRepository { suspend fun fetchAnimal( id: AnimalId ): Animal suspend fun feedAnimal( id: AnimalId ) fun observeAnimals(): Flow<List<Animal>> fun observeEnclosure( id: EnclosureId ): Flow<Enclosure> } |
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:
|
1 2 3 4 |
fun getCapybara(): Flow<Capybara> = flow { emit(api.getCapybara()) } |
But if this API inherently performs one request and returns one value, ask what Flow is actually buying you.
Often:
|
1 2 |
suspend fun getCapybara(): Capybara = api.getCapybara() |
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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
class ZooViewModel( private val repository: ZooRepository ) : ViewModel() { private val selectedEnclosure = MutableStateFlow<EnclosureId?>(null) val uiState: StateFlow<ZooUiState> = combine( repository.observeAnimals(), selectedEnclosure ) { animals, enclosure -> ZooUiState( animals = animals, selectedEnclosure = enclosure ) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = ZooUiState() ) fun feedCapybara(id: AnimalId) { viewModelScope.launch { repository.feedAnimal(id) } } } |
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:
|
1 2 |
val uiState by viewModel.uiState .collectAsStateWithLifecycle() |
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.
|
1 |
suspend fun howManyOttersAreThere(): Int |
If you’re subscribing to an answer that can change, Flow often fits.
|
1 |
fun observeOtterCount(): Flow<Int> |
If you’re representing the latest state, StateFlow often fits.
|
1 |
val otterUiState: StateFlow<OtterUiState> |
If you’re broadcasting occurrences to multiple listeners, SharedFlow may fit.
|
1 |
val otterIncidents: SharedFlow<OtterIncident> |
And if you’re doing several asynchronous pieces of work together, you’re back in coroutine territory:
|
1 2 3 4 5 6 7 8 9 |
coroutineScope { val otters = async { countOtters() } val fish = async { countFish() } calculateImpendingFishShortage( otters.await(), fish.await() ) } |
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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
One result? suspend Values over time? Flow Current observable state? StateFlow Broadcast to several listeners? SharedFlow Lemur driving a Toyota? Stop reading Kotlin articles and call zoo security. |
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:
|
1 2 3 4 5 6 7 8 9 10 |
<strong>Kotlin Swift C#</strong> suspend fun async function Task<T> launch Task { ... } Task.Run-ish* async / await() async let / Task.value Task<T> / await Flow<T> AsyncSequence IAsyncEnumerable<T> StateFlow<T> CurrentValueSubject-ish BehaviorSubject-ish SharedFlow<T> PassthroughSubject-ish Subject-ish collect for await / subscription await foreach |
*These comparisons are deliberately approximate. The concurrency models and lifecycle rules are not identical.
And, most importantly:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
suspend "This work may need to wait." Flow "Values may keep arriving." StateFlow "What is the latest state?" SharedFlow "Tell everyone currently listening." launch "Go do this." async "Go do this, and I'll want the answer later." await "Okay, I want the answer now." Gerald the giraffe "Still suspicious." |
![]()
Comments