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.
Search for a command to run...
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.
No comments yet. Be the first to comment.
This guide explains what Channels are, when to use them, how to use them correctly, and when NOT to use them — in simple, professional language. 1. What problem do Channels solve? In Kotlin coroutines, you often have multiple coroutines running at t...
This guide explains what Channels are, when to use them, how to use them correctly, and when NOT to use them — in simple, professional language. 1. What problem do Channels solve? In Kotlin coroutines, you often have multiple coroutines running at t...

Learn how to build preview-safe ViewModels in Jetpack Compose using Hilt.

HandlerInterceptor vs Filter in Spring Boot: A Practical Guide with JNDI Injection Prevention

Simple In-Memory Caching: A Tiny Trick with Massive Impact

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.

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 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
// 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)
}
}
// 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)
}
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)

// 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)
}
}
// 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)
}
}
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
}
}
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.

// 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)
}
}
}
// 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)
}
}
}
}

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
}
}
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()
)
}
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)
}
}
}
// 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)
}
}
}
// 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)
}
}
}
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()

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)
StateFlow: Stores one value in memory
SharedFlow: Stores replay cache + buffer
Channel: Stores buffer (size depends on capacity)
Regular Flow: No storage (cold stream)
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)
Each Flow collector creates independent execution
SharedFlow shares execution among collectors
Channel has no collectors (only receivers)

@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()
}
@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()
}
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…