Skip to main content

Command Palette

Search for a command to run...

Kotlin : Channels vs Flow in Practice

A practical guide to choosing the right coroutine primitive for your Android/Kotlin projects

Updated
8 min read
Kotlin : Channels vs Flow in Practice
R

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:

  1. Coroutine-native: No lifecycle observers needed

  2. Null safety: Must have initial value

  3. Better testing: Can be collected in tests easily

  4. 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:

  1. Multiple collectors: Multiple screens can listen

  2. No lifecycle issues: SharedFlow is safer with lifecycle

  3. Better operators: Can use catch, onEach, etc.

  4. Cleaner API: No need to wrap in receiveAsFlow()

Chapter 8: Decision Framework

Quick Decision Tree:

  1. Are you representing UI state that should always have a value?

    • StateFlow

    • ❌ Not Channel, not regular Flow

  2. Are you emitting one-time events (snackbars, navigation)?

    • SharedFlow (replay = 0)

    • ❌ Not Channel, not StateFlow

  3. Do multiple collectors need the same data stream?

    • SharedFlow (with appropriate replay)

    • ❌ Not Channel (single consumer)

  4. Is each piece of data consumed by exactly one worker?

    • Channel (work queues, task distribution)

    • ❌ Not Flow, not SharedFlow

  5. Do you need a cold stream that starts fresh for each collector?

    • Regular Flow

    • ❌ Not Channel (hot), not StateFlow (always has value)

  6. 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

  1. UI State = StateFlow (always)

  2. Events = SharedFlow (almost always)

  3. Data streams = Flow or SharedFlow (depending on sharing needs)

  4. Work coordination = Channel (rare, specific cases)

  5. 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…