If you’ve used Kotlin Flow, StateFlow, SharedFlow, and Channels, you’ve seen the terms “cold” and “hot” everywhere. But what do they actually mean? Why does it matter? And how do you pick the right one for your use case? This guide gives you the complete mental model in one place — with visual diagrams for every type, a side-by-side comparison, and a clear decision tree so you never pick the wrong stream type again.
The Core Concept
// Think of it like TV:
//
// COLD stream = Netflix
// You press play → the show starts FROM THE BEGINNING
// Each viewer gets their OWN independent playback
// Nothing plays until someone presses play
// Two viewers watching the same show get separate streams
//
// HOT stream = Live TV broadcast
// The broadcast is running WHETHER YOU WATCH OR NOT
// You tune in → you see whatever is airing RIGHT NOW
// You missed the first 10 minutes? They're gone
// All viewers see the SAME broadcast at the SAME time
// In Kotlin terms:
// COLD = Flow { } → nothing happens until collect()
// HOT = StateFlow → always has a value, runs independently
// HOT = SharedFlow → broadcasts to all current collectors
// HOT = Channel → sends values regardless of receivers
Cold Streams — Flow
Flow is an interface from kotlinx.coroutines.flow. The flow { } builder is a top-level function that creates a cold Flow.
// ═══ COLD FLOW ═══════════════════════════════════════════════════════
//
// BEFORE collect():
//
// flow { } (nobody)
// ┌──────────┐
// │ 💤 IDLE │ No code runs
// │ No work │ No values produced
// │ Nothing │ Just a description of work TO DO
// └──────────┘
//
//
// Collector A calls .collect():
//
// flow { } Collector A
// ┌──────────┐ ┌──────────┐
// │ STARTS! │──── 1 ──→ │ gets 1 │
// │ emit(1) │ │ │
// │ emit(2) │──── 2 ──→ │ gets 2 │
// │ emit(3) │──── 3 ──→ │ gets 3 │
// │ DONE │ │ DONE │
// └──────────┘ └──────────┘
//
//
// Collector B calls .collect() SEPARATELY:
//
// flow { } Collector B
// ┌──────────┐ ┌──────────┐
// │ STARTS │──── 1 ──→ │ gets 1 │ ← runs from scratch!
// │ AGAIN! │ │ │ completely independent
// │ emit(1) │ │ │ from Collector A
// │ emit(2) │──── 2 ──→ │ gets 2 │
// │ emit(3) │──── 3 ──→ │ gets 3 │
// │ DONE │ │ DONE │
// └──────────┘ └──────────┘
//
// Key properties of COLD:
// • Nothing runs until collect() is called
// • Each collector gets its OWN execution (fresh start)
// • Producer and consumer are 1:1
// • Flow completes when the block finishes
// • Cancelling the collector cancels the flow
// Code example
val coldFlow: Flow<Int> = flow { // flow { } is a TOP-LEVEL FUNCTION
println("Flow started") // doesn't print until collect()
emit(1) // emit() is a SUSPEND FUNCTION on FlowCollector
delay(1000)
emit(2)
delay(1000)
emit(3)
}
// Nothing has happened yet — flow is just a description
// Collector A
coldFlow.collect { println("A: $it") }
// Output: Flow started → A: 1 → A: 2 → A: 3
// Collector B (later)
coldFlow.collect { println("B: $it") }
// Output: Flow started → B: 1 → B: 2 → B: 3
// "Flow started" prints AGAIN — completely new execution
Hot Streams — StateFlow
StateFlow is an interface that extends SharedFlow. MutableStateFlow() is a top-level function that creates a mutable instance.
// ═══ HOT: StateFlow ══════════════════════════════════════════════════
//
// StateFlow ALWAYS has a value — even with zero collectors
//
// MutableStateFlow("A")
// ┌──────────────────────┐
// │ current value = "A" │ (no collectors yet)
// │ .value is accessible │ (value exists anyway)
// └──────────────────────┘
//
//
// Collector 1 starts:
//
// StateFlow Collector 1
// ┌──────────────────────┐ ┌──────────────────────┐
// │ value = "A" │── A ──→│ immediately gets "A" │
// └──────────────────────┘ └──────────────────────┘
// ↑ latest value delivered instantly
//
// Producer updates value:
//
// StateFlow Collector 1
// ┌──────────────────────┐ ┌──────────────────────┐
// │ value = "A" → "B" │── B ──→│ gets "B" │
// └──────────────────────┘ └──────────────────────┘
//
// Producer updates with SAME value:
//
// StateFlow Collector 1
// ┌──────────────────────┐ ┌──────────────────────┐
// │ value = "B" → "B" │ ✗ │ NOTHING (skipped) │
// └──────────────────────┘ └──────────────────────┘
// ↑ duplicate IGNORED (conflated)
//
// Collector 2 joins LATER:
//
// StateFlow Collector 1 Collector 2
// ┌──────────────────────┐ ┌──────────┐ ┌──────────────────┐
// │ value = "B" │ │ (active) │ │ gets "B" instant │
// │ │── C ──→│ gets "C" │ │ gets "C" │
// │ value = "B" → "C" │ │ │ │ │
// └──────────────────────┘ └──────────┘ └──────────────────┘
// ↑ got latest on join
//
// Key properties:
// • ALWAYS has a current value (.value property)
// • New collectors IMMEDIATELY get the latest value (replay = 1)
// • Duplicate values are SKIPPED (distinctUntilChanged built-in)
// • One producer → many consumers (1:N broadcast)
// • Never completes (collectors suspend forever waiting for updates)
// Code example
val _state = MutableStateFlow("A") // MutableStateFlow() is a TOP-LEVEL FUNCTION
// MutableStateFlow is a CLASS that implements MutableStateFlow interface
val state: StateFlow<String> = _state // StateFlow is an INTERFACE (read-only)
println(state.value) // "A" — value exists even without collectors
// Update value
_state.value = "B"
_state.value = "B" // ignored — same value (conflated)
_state.value = "C" // collectors notified
// .update {} is an EXTENSION FUNCTION on MutableStateFlow — thread-safe
_state.update { current -> current + "!" }
Hot Streams — SharedFlow
SharedFlow is an interface that extends Flow. MutableSharedFlow() is a top-level function that creates a mutable instance.
// ═══ HOT: SharedFlow (replay = 0) ════════════════════════════════════
//
// SharedFlow does NOT hold a current value — it broadcasts events
//
// MutableSharedFlow<String>()
// ┌──────────────────────┐
// │ (no value stored) │ (no collectors yet)
// │ (no .value property) │
// └──────────────────────┘
//
//
// Collector 1 starts:
//
// SharedFlow Collector 1
// ┌──────────────────────┐ ┌──────────────────────┐
// │ │ │ waiting... │
// └──────────────────────┘ └──────────────────────┘
// ↑ gets NOTHING (no replay, no current value)
//
// Producer emits:
//
// SharedFlow Collector 1
// ┌──────────────────────┐ ┌──────────────────────┐
// │ emit("X") │── X ──→│ gets "X" │
// │ emit("Y") │── Y ──→│ gets "Y" │
// │ emit("Y") │── Y ──→│ gets "Y" (AGAIN!) │
// └──────────────────────┘ └──────────────────────┘
// ↑ duplicates ARE delivered (no conflation)
//
// Emit with NO collectors:
//
// SharedFlow (nobody listening)
// ┌──────────────────────┐
// │ emit("Z") │── Z ──→ 💨 LOST! nobody received it
// └──────────────────────┘
//
// Collector 2 joins AFTER "X", "Y", "Z" were emitted:
//
// SharedFlow Collector 2
// ┌──────────────────────┐ ┌──────────────────────┐
// │ │ │ gets NOTHING │
// └──────────────────────┘ └──────────────────────┘
// ↑ missed events are GONE (replay = 0)
//
// Future emissions go to ALL current collectors:
//
// SharedFlow Collector 1 Collector 2
// ┌──────────────────────┐ ┌──────────┐ ┌──────────┐
// │ emit("W") │── W ──→│ gets "W" │ │ gets "W" │
// └──────────────────────┘ └──────────┘ └──────────┘
//
// Key properties:
// • NO current value — no .value property
// • New collectors get NOTHING from the past (with replay = 0)
// • Duplicates ARE delivered (no conflation)
// • Emissions without collectors are LOST (with replay = 0)
// • Configurable: replay count, buffer size, overflow strategy
// • Never completes
// Code example
val _events = MutableSharedFlow<String>() // TOP-LEVEL FUNCTION
// MutableSharedFlow is an INTERFACE
val events: SharedFlow<String> = _events // SharedFlow is an INTERFACE (read-only)
// _events.value — ❌ COMPILE ERROR! SharedFlow has no .value
// Emit — emit() is a SUSPEND FUNCTION on MutableSharedFlow
viewModelScope.launch {
_events.emit("ShowSnackbar") // suspends if buffer is full and no collectors
}
// With replay:
val replayEvents = MutableSharedFlow<String>(replay = 1)
// New collectors immediately get the last 1 emitted value
// With buffer:
val bufferedEvents = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
// BufferOverflow is a CLASS with three values: SUSPEND, DROP_OLDEST, DROP_LATEST
)
Hot Streams — Channel
Channel is an interface from kotlinx.coroutines.channels. Channel() is a top-level function that creates a channel instance.
// ═══ HOT: Channel ════════════════════════════════════════════════════
//
// Channel is a PIPE between coroutines — each value goes to ONE receiver
//
// Producer Channel (buffer) Consumer
// (Coroutine A) [ queue ] (Coroutine B)
//
// send(1) ──────→ [ 1 ] ──────→ receive() → 1
// send(2) ──────→ [ 2 ] ──────→ receive() → 2
// send(3) ──────→ [ 3 ] ──────→ receive() → 3
// close() [CLOSED] loop exits
//
//
// With MULTIPLE consumers (fan-out):
//
// Producer Channel Consumer A Consumer B
// send(1) ──→ [ ] ──→ gets 1
// send(2) ──→ [ ] gets 2
// send(3) ──→ [ ] ──→ gets 3
// send(4) ──→ [ ] gets 4
//
// Values are SPLIT — each value goes to exactly ONE consumer
// NOT broadcast! (unlike Flow/StateFlow/SharedFlow)
//
//
// Key difference from Flow:
//
// Flow (cold): Producer ──→ Consumer A gets [1,2,3]
// Producer ──→ Consumer B gets [1,2,3] (separate run)
//
// Channel (hot): Producer ──→ Consumer A gets [1,3]
// └─→ Consumer B gets [2,4] (values split)
//
// StateFlow (hot): Producer ──→ Consumer A gets latest
// └─→ Consumer B gets latest (both get same)
//
// Key properties:
// • HOT — values are sent regardless of receivers
// • Each value goes to exactly ONE receiver (not broadcast)
// • Supports buffering (Rendezvous, Buffered, Unlimited, Conflated)
// • Must be closed manually (or use produce { } builder)
// • send() SUSPENDS when buffer is full
// • receive() SUSPENDS when buffer is empty
// Code example
val channel = Channel<Int>() // Channel() is a TOP-LEVEL FUNCTION
// Channel is an INTERFACE — SendChannel + ReceiveChannel
// send() is a SUSPEND FUNCTION on SendChannel
// receive() is a SUSPEND FUNCTION on ReceiveChannel
// Producer
launch {
channel.send(1)
channel.send(2)
channel.close() // close() is a FUNCTION on SendChannel
}
// Consumer
launch {
for (value in channel) { // iterates until closed
println(value)
}
}
// trySend() is a NON-SUSPEND FUNCTION — returns ChannelResult immediately
// Safe to call from callbacks where you can't suspend
channel.trySend(42)
The Complete Comparison
// ═══ ALL FOUR TYPES — SIDE BY SIDE ═══════════════════════════════════
//
// ┌──────────────────┬───────────┬──────────────┬──────────────┬───────────────┐
// │ │ Flow │ StateFlow │ SharedFlow │ Channel │
// ├──────────────────┼───────────┼──────────────┼──────────────┼───────────────┤
// │ Cold/Hot │ COLD │ HOT │ HOT │ HOT │
// │ Runs when │ collect() │ Always │ Always │ Always │
// │ Consumers │ 1 per │ Many (1:N) │ Many (1:N) │ 1 per value │
// │ │ collect │ broadcast │ broadcast │ (split) │
// │ Has .value │ No │ Yes │ No │ No │
// │ Replay │ N/A │ Always 1 │ 0..N config │ N/A │
// │ Duplicates │ N/A │ Skipped │ Delivered │ Delivered │
// │ Completes │ Yes │ Never │ Never │ When closed │
// │ Backpressure │ Suspends │ Conflated │ Configurable │ Suspends │
// │ Best for │ Data │ UI State │ Events │ Coroutine │
// │ │ pipelines │ (loading, │ (snackbar, │ communication │
// │ │ │ success,err) │ navigation) │ (work queues) │
// │ Android UI use │ Transform │ Primary │ One-time │ Rare │
// │ │ + stateIn │ state holder │ events │ │
// └──────────────────┴───────────┴──────────────┴──────────────┴───────────────┘
Decision Tree — Which One to Use
// Start here:
//
// Q: Do you need to OBSERVE data in the UI?
// ├── YES → Q: Does the UI need the CURRENT state?
// │ ├── YES → StateFlow ✅
// │ │ (loading/success/error, form state, settings)
// │ │
// │ └── NO → Q: Is it a ONE-TIME event?
// │ ├── YES → SharedFlow (replay=0) ✅
// │ │ (snackbar, navigation, toast)
// │ │
// │ └── NO → StateFlow ✅
// │ (default choice for UI)
// │
// └── NO → Q: Are you passing work BETWEEN coroutines?
// ├── YES → Q: Should each value go to ONE worker?
// │ ├── YES → Channel ✅
// │ │ (work queue, fan-out, rate limiting)
// │ │
// │ └── NO → SharedFlow ✅
// │ (event bus, broadcast)
// │
// └── NO → Q: Are you building a data PIPELINE?
// ├── YES → Cold Flow ✅
// │ (repository → transform → stateIn → UI)
// │
// └── NO → Cold Flow ✅
// (default for data operations)
Converting Between Cold and Hot
// ═══ COLD → HOT conversion ═══════════════════════════════════════════
//
// Cold Flow stateIn() StateFlow (HOT)
// ┌──────────┐ ┌──────────┐ ┌──────────────┐
// │ flow { } │ ─── data ───→ │ converts │ ─── data ──→ │ holds latest │
// │ (idle) │ │ cold→hot │ │ for all │
// └──────────┘ └──────────┘ │ collectors │
// └──────────────┘
//
// Cold Flow shareIn() SharedFlow (HOT)
// ┌──────────┐ ┌──────────┐ ┌──────────────┐
// │ flow { } │ ─── data ───→ │ converts │ ─── data ──→ │ broadcasts │
// │ (idle) │ │ cold→hot │ │ to all │
// └──────────┘ └──────────┘ │ collectors │
// └──────────────┘
// stateIn() — EXTENSION FUNCTION on Flow, converts to StateFlow
// shareIn() — EXTENSION FUNCTION on Flow, converts to SharedFlow
// Typical Android pattern:
// Repository returns COLD Flow (data pipeline)
// ViewModel converts to HOT StateFlow (UI observes)
// Repository (cold — just a pipeline definition)
class ArticleRepository(private val dao: ArticleDao) {
fun getArticles(): Flow<List<Article>> = dao.getArticles() // cold Flow from Room
.map { articles -> articles.filter { it.isPublished } } // transform
// Nothing runs yet — just a pipeline description
}
// ViewModel (converts cold → hot for UI)
class ArticleViewModel(repository: ArticleRepository) : ViewModel() {
val articles: StateFlow<List<Article>> = repository.getArticles() // cold
.stateIn( // → hot
scope = viewModelScope,
// scope is where the collection coroutine runs
// viewModelScope is an EXTENSION PROPERTY on ViewModel
started = SharingStarted.WhileSubscribed(5000),
// SharingStarted is an INTERFACE with companion factory functions
// WhileSubscribed is a FUNCTION that returns a SharingStarted instance
initialValue = emptyList()
)
// Now articles is HOT — always has a value, all collectors share it
}
// Fragment/Compose (observes hot StateFlow)
// collectAsStateWithLifecycle() is an EXTENSION FUNCTION on StateFlow
val articles by viewModel.articles.collectAsStateWithLifecycle()
Common Misconceptions
// MISCONCEPTION 1: "Cold means slow, hot means fast"
// WRONG — cold/hot is about WHEN values are produced, not speed
// A cold Flow can be faster than a hot StateFlow
// MISCONCEPTION 2: "StateFlow is just LiveData for coroutines"
// PARTIALLY TRUE — similar role (observable state) but:
// - StateFlow is pure Kotlin (no Android dependency)
// - StateFlow requires initial value (LiveData doesn't)
// - StateFlow skips duplicates (LiveData doesn't)
// - StateFlow works in Kotlin Multiplatform
// MISCONCEPTION 3: "SharedFlow is like Channel"
// WRONG — SharedFlow BROADCASTS to ALL collectors (1:N)
// Channel DELIVERS to ONE receiver (1:1 per value)
// SharedFlow: all collectors get the event
// Channel: only one consumer gets each value
// MISCONCEPTION 4: "Hot means it runs forever"
// NOT EXACTLY — hot means it exists independently of collectors
// StateFlow has a value even with zero collectors
// But the upstream cold Flow feeding it can be stopped
// (WhileSubscribed stops upstream when no collectors)
// MISCONCEPTION 5: "I should always use hot streams"
// WRONG — cold Flows are BETTER for data pipelines
// Use cold Flow in Repository (lazy, composable, testable)
// Convert to hot only at the ViewModel layer (stateIn/shareIn)
Real-World Architecture — Cold to Hot Pipeline
// The standard Android architecture uses BOTH cold and hot:
//
// DATABASE REPOSITORY VIEWMODEL UI
// (cold source) (cold pipeline) (cold → hot) (observes hot)
//
// Room DAO transform, stateIn() / collectAsState
// returns filter, map, shareIn() WithLifecycle()
// Flow<List<T>> combine
//
// ┌─────────┐ ┌─────────────┐ ┌──────────────┐ ┌──────────┐
// │ COLD │──Flow──→ │ COLD │─Flow─→│ HOT │─ST─→│ UI │
// │ Room DB │ │ Repository │ │ StateFlow │ │ Compose │
// │ │ │ (transform) │ │ (stateIn) │ │ Fragment │
// └─────────┘ └─────────────┘ └──────────────┘ └──────────┘
//
// Cold pipeline: lazy, efficient, composable, testable
// Hot endpoint: always has value, shared across collectors, lifecycle-aware
//
// Events use SharedFlow:
// ViewModel ──SharedFlow──→ UI (snackbar, navigation)
Summary
- Cold means the stream doesn’t produce values until someone collects — like pressing play on Netflix
- Hot means the stream exists independently of collectors — like a live TV broadcast
- Flow (cold) — lazy data pipeline, each collector gets its own fresh execution, 1:1
- StateFlow (hot) — always holds a current value, new collectors get latest immediately, skips duplicates, 1:N
- SharedFlow (hot) — no current value, broadcasts events to all current collectors, configurable replay, 1:N
- Channel (hot) — pipe between coroutines, each value to exactly one receiver, supports buffering, 1:1 per value
- StateFlow for UI state (loading, success, error)
- SharedFlow for one-time events (snackbar, navigation)
- Channel for coroutine-to-coroutine work distribution (queues, fan-out)
- Cold Flow for data pipelines (repository layer, transformations)
- Convert cold → hot with
stateIn()(→ StateFlow) orshareIn()(→ SharedFlow) - Use cold flows in repositories (lazy, composable) and hot flows in ViewModels (shared, always-ready)
Once the cold/hot mental model clicks, the entire reactive layer of Android architecture makes sense. Cold flows are pipelines you build — they describe what to do with data. Hot flows are endpoints you observe — they hold or broadcast the result. The standard pattern is simple: cold in the data layer, hot in the presentation layer, and stateIn is the bridge between them.
Happy coding!
Comments (0)