Kotlin : Channels vs Flow in Practice
A practical guide to choosing the right coroutine primitive for your Android/Kotlin projects

Senior Android Engineer from Bangladesh. Love to contribute in Open-Source. Indie Music Producer.
Introduction
Choosing between Channels and Flow causes more confusion than almost any other Kotlin coroutine concept. This isn't about which is "better" - they solve different problems. This guide provides clear, practical rules with real examples you can use immediately in your projects.

Chapter 1: The Fundamental Distinction
Channels: Communication Mechanism
Channels are for coroutine-to-coroutine communication. Think of them as pipes where one coroutine puts data in and another takes it out. Each piece of data is consumed exactly once.
Flow: Data Stream Abstraction
Flow is for asynchronous data streams. Think of them as sequences of values over time that can be transformed, combined, and processed.
Simple Analogy:
Channel = Handing off a physical document to a coworker (once they take it, you don't have it anymore)
Flow = A live data feed that multiple people can watch simultaneously
Chapter 2: Channel in Practice
When You Absolutely Need a Channel
Example 1: Work Queue Pattern
// Image processing pipeline - each image should be processed exactly once
class ImageProcessor {
private val processingChannel = Channel<ImageJob>(capacity = Channel.UNLIMITED)
init {
launchProcessorWorkers(count = 4)
}
private fun launchProcessorWorkers(count: Int) {
repeat(count) { workerId ->
launch(Dispatchers.IO) {
for (job in processingChannel) {
// Each job is consumed by exactly one worker
processImage(job)
}
}
}
}
suspend fun submitJob(job: ImageJob) {
processingChannel.send(job)
}
}
Example 2: Request-Response Coordination
// Communication between two specific coroutines
suspend fun coordinateTask() {
val responseChannel = Channel<Result>()
launch {
// Worker does some work
val result = performComplexCalculation()
responseChannel.send(result)
}
// Original coroutine waits for the response
val result = responseChannel.receive()
handleResult(result)
}
Channel Characteristics to Remember:
Single consumption: Each element has exactly one consumer
Hot: Produces values even without collectors
Blocking send: Can suspend if buffer is full (backpressure)
ConflatedChannel: Drops previous value when buffer is full (useful for latest state)

Chapter 3: Flow in Practice
When to Use Regular Flow
Example 1: Database Observation
// Each collector gets its own independent stream
@Dao
interface UserDao {
@Query("SELECT * FROM users")
fun observeUsers(): Flow<List<User>>
}
class UserRepository {
fun getActiveUsers(): Flow<List<User>> =
userDao.observeUsers()
.map { users -> users.filter { it.isActive } }
.flowOn(Dispatchers.IO)
}
// In ViewModel
val activeUsers: Flow<List<User>> = repository.getActiveUsers()
// In UI (each collector starts fresh)
lifecycleScope.launch {
activeUsers.collect { users ->
updateUserList(users)
}
}
Example 2: Network Data Stream
// Cold stream - nothing happens until collection
fun fetchStockPrices(symbol: String): Flow<PriceUpdate> = flow {
val webSocket = createWebSocketConnection(symbol)
try {
while (true) {
val update = webSocket.receiveUpdate()
emit(update)
}
} finally {
webSocket.close()
}
}
// Each collector creates a new WebSocket connection
viewModelScope.launch {
fetchStockPrices("GOOGL")
.collect { update ->
updateUi(update)
}
}
Chapter 4: StateFlow for UI State (Essential)
The Right Way to Handle UI State
Example: Screen State Management
data class LoginScreenState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val isLoggedIn: Boolean = false
)
class LoginViewModel : ViewModel() {
// Private mutable state
private val _uiState = MutableStateFlow(LoginScreenState())
// Public immutable state
val uiState: StateFlow<LoginScreenState> = _uiState
// State updates
fun onEmailChanged(email: String) {
_uiState.update { it.copy(email = email) }
}
fun onPasswordChanged(password: String) {
_uiState.update { it.copy(password = password) }
}
suspend fun login() {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val result = authRepository.login(
email = _uiState.value.email,
password = _uiState.value.password
)
_uiState.update {
it.copy(isLoading = false, isLoggedIn = result.success)
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, error = e.message)
}
}
}
}
// In Activity/Fragment
lifecycleScope.launch {
viewModel.uiState
.distinctUntilChanged()
.collect { state ->
// React to state changes
binding.emailEditText.setText(state.email)
binding.progressBar.isVisible = state.isLoading
binding.errorText.text = state.error
}
}
Why StateFlow Beats LiveData for UI State:
Coroutine-native: No lifecycle observers needed
Null safety: Must have initial value
Better testing: Can be collected in tests easily
Flow operators:
map,filter,combine, etc.

Chapter 5: SharedFlow for Events
Handling One-Time Events Correctly
Example 1: UI Events (Snackbar, Navigation)
// Common mistake: Using Channel for events
// WRONG: val events = Channel<Event>()
// CORRECT: Using SharedFlow
class EventViewModel : ViewModel() {
// Private mutable flow with replay for new collectors
private val _events = MutableSharedFlow<UiEvent>(
replay = 0, // No replay for one-time events
extraBufferCapacity = 10
)
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
fun showMessage(message: String) {
viewModelScope.launch {
_events.emit(UiEvent.ShowSnackbar(message))
}
}
fun navigateTo(destination: String) {
viewModelScope.launch {
_events.emit(UiEvent.Navigate(destination))
}
}
}
sealed class UiEvent {
data class ShowSnackbar(val message: String) : UiEvent()
data class Navigate(val destination: String) : UiEvent()
}
// In Activity/Fragment - safely collect events
lifecycleScope.launch {
viewModel.events
.catch { e -> Log.e("EventCollection", "Error", e) }
.collect { event ->
when (event) {
is UiEvent.ShowSnackbar -> showSnackbar(event.message)
is UiEvent.Navigate -> navigateTo(event.destination)
}
}
}
Example 2: Broadcast Updates
// Multiple observers need same updates
class LocationManager {
private val _locationUpdates = MutableSharedFlow<Location>(
replay = 1, // New observers get last location
extraBufferCapacity = 50
)
val locationUpdates: SharedFlow<Location> = _locationUpdates.asSharedFlow()
fun startListening() {
// Simulate location updates
launch {
while (true) {
val location = fetchCurrentLocation()
_locationUpdates.emit(location)
delay(1000)
}
}
}
}
// Multiple screens can observe the same location
class MapFragment {
init {
lifecycleScope.launch {
locationManager.locationUpdates.collect { location ->
updateMap(location)
}
}
}
}
class WeatherFragment {
init {
lifecycleScope.launch {
locationManager.locationUpdates.collect { location ->
updateWeather(location)
}
}
}
}

Chapter 6: Common Patterns and Solutions
Pattern 1: Search with Debounce
class SearchViewModel : ViewModel() {
private val searchQuery = MutableStateFlow("")
val searchResults: StateFlow<SearchResult> = searchQuery
.debounce(300) // Wait 300ms after last keystroke
.distinctUntilChanged()
.filter { it.length >= 3 } // Only search if 3+ chars
.mapLatest { query -> // Cancel previous search
if (query.isEmpty()) {
SearchResult.Empty
} else {
SearchResult.Loading
try {
SearchResult.Success(repository.search(query))
} catch (e: Exception) {
SearchResult.Error(e.message)
}
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = SearchResult.Empty
)
fun onQueryChanged(query: String) {
searchQuery.value = query
}
}
Pattern 2: Combining Multiple Flows
class DashboardViewModel : ViewModel() {
private val userFlow = userRepository.observeUser()
private val messagesFlow = chatRepository.observeMessages()
private val notificationsFlow = notificationRepository.observeNotifications()
val dashboardState: StateFlow<DashboardState> = combine(
userFlow,
messagesFlow,
notificationsFlow
) { user, messages, notifications ->
DashboardState(
userName = user.name,
unreadMessages = messages.count { !it.isRead },
notificationCount = notifications.size,
lastMessage = messages.lastOrNull()?.preview
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = DashboardState()
)
}
Pattern 3: Retry with Exponential Backoff
fun fetchWithRetry(): Flow<Data> = flow {
var currentDelay = 1000L // Start with 1 second
val maxDelay = 30000L // Max 30 seconds
while (true) {
try {
val data = api.fetchData()
emit(data)
delay(5000) // Normal delay between successful fetches
} catch (e: IOException) {
// Exponential backoff on failure
delay(currentDelay)
currentDelay = (currentDelay * 2).coerceAtMost(maxDelay)
}
}
}
Chapter 7: Migration Guide (Channel → SharedFlow)
Before (Old Channel Pattern):
// OLD: Channel for events
class OldViewModel {
private val eventChannel = Channel<Event>(Channel.BUFFERED)
val events = eventChannel.receiveAsFlow()
fun sendEvent(event: Event) {
viewModelScope.launch {
eventChannel.send(event)
}
}
}
After (Modern SharedFlow):
// NEW: SharedFlow for events
class NewViewModel {
private val _events = MutableSharedFlow<Event>(
extraBufferCapacity = 64
)
val events = _events.asSharedFlow()
fun sendEvent(event: Event) {
viewModelScope.launch {
_events.emit(event)
}
}
}
Benefits of Migration:
Multiple collectors: Multiple screens can listen
No lifecycle issues: SharedFlow is safer with lifecycle
Better operators: Can use
catch,onEach, etc.Cleaner API: No need to wrap in
receiveAsFlow()

Chapter 8: Decision Framework
Quick Decision Tree:
Are you representing UI state that should always have a value?
✅ StateFlow
❌ Not Channel, not regular Flow
Are you emitting one-time events (snackbars, navigation)?
✅ SharedFlow (replay = 0)
❌ Not Channel, not StateFlow
Do multiple collectors need the same data stream?
✅ SharedFlow (with appropriate replay)
❌ Not Channel (single consumer)
Is each piece of data consumed by exactly one worker?
✅ Channel (work queues, task distribution)
❌ Not Flow, not SharedFlow
Do you need a cold stream that starts fresh for each collector?
✅ Regular Flow
❌ Not Channel (hot), not StateFlow (always has value)
Are you coordinating between two specific coroutines?
✅ Channel (request-response patterns)
❌ Not SharedFlow (broadcast)
Chapter 9: Performance Considerations
Memory Usage:
StateFlow: Stores one value in memory
SharedFlow: Stores replay cache + buffer
Channel: Stores buffer (size depends on capacity)
Regular Flow: No storage (cold stream)
Backpressure Handling:
Channel: Suspends sender when buffer full
Flow: Built-in backpressure via suspension
SharedFlow: Drops or buffers based on configuration
StateFlow: Always accepts new values (replaces old)
Collector Overhead:
Each Flow collector creates independent execution
SharedFlow shares execution among collectors
Channel has no collectors (only receivers)

Chapter 10: Testing Strategies
Testing StateFlow:
@Test
fun `uiState should update on login success`() = runTest {
val viewModel = LoginViewModel(mockRepository)
// Set up initial state
viewModel.onEmailChanged("[email protected]")
viewModel.onPasswordChanged("password")
// Collect values
val collectedStates = mutableListOf<LoginScreenState>()
val job = launch {
viewModel.uiState.collect { collectedStates.add(it) }
}
// Trigger action
viewModel.login()
// Verify state transitions
assertThat(collectedStates).containsExactly(
LoginScreenState(email = "[email protected]", password = "password"),
LoginScreenState(email = "[email protected]", password = "password", isLoading = true),
LoginScreenState(email = "[email protected]", password = "password", isLoggedIn = true)
)
job.cancel()
}
Testing SharedFlow Events:
@Test
fun `should emit snackbar event on error`() = runTest {
val viewModel = EventViewModel()
val events = mutableListOf<UiEvent>()
// Collect events
val job = launch {
viewModel.events.collect { events.add(it) }
}
// Trigger error
viewModel.showMessage("Error occurred")
// Verify event was emitted
assertThat(events).containsExactly(
UiEvent.ShowSnackbar("Error occurred")
)
job.cancel()
}
Summary: Rules of Thumb
UI State = StateFlow (always)
Events = SharedFlow (almost always)
Data streams = Flow or SharedFlow (depending on sharing needs)
Work coordination = Channel (rare, specific cases)
When in doubt, start with Flow/SharedFlow, not Channel
Remember: Channels are low-level primitives. Most application code should use the higher-level abstractions (Flow, StateFlow, SharedFlow). Reserve Channels for specific coordination patterns where you truly need single-consumer semantics.
That’s it for today. Happy coding…



