Kotlin Channels are how coroutines communicate with each other. While Flow is designed for one-to-many reactive streams, a Channel is a one-to-one pipeline — one coroutine sends, another receives. Think of it like a BlockingQueue, but built for coroutines — instead of blocking threads, it suspends coroutines. Channels are useful for producer-consumer patterns, fan-out work distribution, coordinating parallel tasks, and bridging callback-based APIs. This guide covers how Channels work, the different channel types, when to use them (and when not to), and real patterns for Android development.


What is a Channel?

A Channel is a hot, suspending queue that allows coroutines to send and receive values safely:

val channel = Channel<Int>()

// Producer coroutine — sends values
launch {
    channel.send(1)
    channel.send(2)
    channel.send(3)
    channel.close()   // signal that no more values are coming
}

// Consumer coroutine — receives values
launch {
    for (value in channel) {   // iterates until channel is closed
        println(value)
    }
}
// Output: 1, 2, 3

Key points about Channels:

  • Hot — values are sent regardless of whether anyone is receiving
  • Suspendingsend() suspends if the channel is full, receive() suspends if the channel is empty
  • One-time delivery — each value is received by exactly one consumer, not broadcast to all
  • Closable — the producer calls close() to signal no more values are coming

Channel vs Flow

Before going deeper, let’s be clear about when to use which:

// ┌──────────────────────┬──────────────────────┬──────────────────────┐
// │                      │     Channel           │     Flow              │
// ├──────────────────────┼──────────────────────┼──────────────────────┤
// │ Hot or Cold           │ Hot                   │ Cold (default)        │
// │ Consumers             │ One (single receiver) │ Many (multiple)       │
// │ Delivery              │ Each value once       │ Each value to all     │
// │ Backpressure          │ Suspends sender       │ Suspends emitter      │
// │ Lifecycle              │ Must close manually   │ Completes naturally   │
// │ Use case              │ Coroutine-to-coroutine│ Reactive data streams │
// │ Android UI            │ Rarely                │ Primary choice        │
// └──────────────────────┴──────────────────────┴──────────────────────┘

// Rule of thumb:
// - Exposing data to UI? → Use Flow (StateFlow / SharedFlow)
// - Two coroutines passing work between them? → Use Channel
// - Broadcasting to multiple observers? → Use Flow
// - Fan-out to multiple workers? → Use Channel

Channel Types

Channels differ in how they buffer values between send and receive. This controls what happens when the sender is faster than the receiver:

Rendezvous (default) — no buffer

val channel = Channel<Int>()   // default: RENDEZVOUS

launch {
    println("Sending 1")
    channel.send(1)        // suspends until someone calls receive()
    println("Sent 1")
    channel.send(2)        // suspends again
    println("Sent 2")
}

launch {
    delay(1000)
    println("Receiving")
    println(channel.receive())   // 1 — sender resumes
    println(channel.receive())   // 2
}
// Output:
// Sending 1               (send suspends immediately)
// Receiving               (after 1s delay)
// 1
// Sent 1                  (sender resumes after receive)
// Sent 2
// 2

Buffered — fixed-size buffer

val channel = Channel<Int>(capacity = 3)   // buffer holds 3 values

launch {
    channel.send(1)   // doesn't suspend — buffer has space
    channel.send(2)   // doesn't suspend
    channel.send(3)   // doesn't suspend
    channel.send(4)   // SUSPENDS — buffer is full, waits for receive
}

// Buffered channels let the producer get ahead of the consumer
// Good for: smoothing out speed differences between producer and consumer

Unlimited — unlimited buffer

val channel = Channel<Int>(Channel.UNLIMITED)

launch {
    repeat(1_000_000) {
        channel.send(it)   // never suspends — buffer grows as needed
    }
}

// ⚠️ Careful — can cause OutOfMemoryError if consumer is too slow
// Good for: when you're certain the consumer will keep up eventually

Conflated — keeps only the latest value

val channel = Channel<Int>(Channel.CONFLATED)

launch {
    channel.send(1)
    channel.send(2)
    channel.send(3)   // 1 and 2 are dropped — only 3 is kept
}

launch {
    delay(1000)
    println(channel.receive())   // 3 — only the latest
}

// Similar to StateFlow behaviour — slow consumers get the latest value
// Good for: status updates where only the most recent matters

When to use which

// Rendezvous (default) — tight synchronization between producer and consumer
//   Example: handoff tasks one at a time

// Buffered              — smooth out temporary speed differences
//   Example: producer bursts, consumer is steady

// Unlimited             — producer must never suspend
//   Example: logging, analytics events (consume at your own pace)

// Conflated             — only latest value matters
//   Example: UI position updates, sensor readings

Sending and Receiving

Basic send and receive

val channel = Channel<String>()

// send() — suspends if channel is full
launch {
    channel.send("Hello")
    channel.send("World")
    channel.close()
}

// receive() — suspends if channel is empty
launch {
    println(channel.receive())   // "Hello"
    println(channel.receive())   // "World"
    // channel.receive()          // would throw ClosedReceiveChannelException
}

Iterating over a channel

// for-in loop — iterates until channel is closed
launch {
    for (value in channel) {
        println(value)
    }
    println("Channel closed, loop done")
}

// consumeEach — same but also consumes (cancels) the channel on exception
launch {
    channel.consumeEach { value ->
        println(value)
    }
}

trySend and tryReceive — non-suspending alternatives

val channel = Channel<Int>(capacity = 1)

// trySend — returns immediately, doesn't suspend
val sendResult = channel.trySend(42)
if (sendResult.isSuccess) {
    println("Sent successfully")
} else {
    println("Channel full or closed")
}

// tryReceive — returns immediately, doesn't suspend
val receiveResult = channel.tryReceive()
if (receiveResult.isSuccess) {
    println("Received: ${receiveResult.getOrNull()}")
} else {
    println("Channel empty or closed")
}

// Useful in callbacks where you can't suspend
val callbackChannel = Channel<Location>(Channel.CONFLATED)

locationClient.requestUpdates { location ->
    callbackChannel.trySend(location)   // non-suspending, safe in callbacks
}

Closing channels

val channel = Channel<Int>()

launch {
    channel.send(1)
    channel.send(2)
    channel.close()   // no more values will be sent

    // channel.send(3)          // ❌ throws ClosedSendChannelException
    // channel.trySend(3)       // returns failure result
}

launch {
    for (value in channel) {
        println(value)           // prints 1, 2 — then loop exits
    }
    // After close:
    println(channel.isClosedForReceive)   // true (after all values consumed)
    println(channel.isClosedForSend)      // true (immediately after close)
}

produce — Channel Builder

The produce builder creates a channel and launches a coroutine that sends values into it. The channel is automatically closed when the block completes:

// produce = launch + Channel, with auto-close
fun CoroutineScope.produceNumbers(): ReceiveChannel<Int> = produce {
    var count = 1
    while (true) {
        send(count++)
        delay(1000)
    }
}
// Channel closes automatically when the coroutine ends or is cancelled

// Usage
val numbers = produceNumbers()
repeat(5) {
    println(numbers.receive())   // 1, 2, 3, 4, 5
}
numbers.cancel()   // cancel the producing coroutine

// produce is preferred over manually creating channels because:
// 1. Auto-closes the channel when done
// 2. Cancels cleanly with structured concurrency
// 3. Less boilerplate

Fan-Out — Multiple Consumers

When multiple coroutines receive from the same channel, each value goes to exactly one consumer. This is the fan-out pattern — distributing work across workers:

val taskChannel = Channel<Task>(capacity = 10)

// Producer — sends tasks
launch {
    for (task in tasks) {
        taskChannel.send(task)
    }
    taskChannel.close()
}

// Multiple workers — each task is processed by exactly one worker
repeat(3) { workerId ->
    launch {
        for (task in taskChannel) {
            println("Worker $workerId processing: ${task.name}")
            task.process()
        }
    }
}

// If 9 tasks are sent:
// Worker 0 might process: Task 1, Task 4, Task 7
// Worker 1 might process: Task 2, Task 5, Task 8
// Worker 2 might process: Task 3, Task 6, Task 9
// Distribution depends on processing speed

Fan-In — Multiple Producers

Multiple coroutines can send to the same channel. This merges multiple sources into one stream:

val eventChannel = Channel<Event>(Channel.UNLIMITED)

// Multiple producers sending to the same channel
launch {
    networkMonitor.events.collect { event ->
        eventChannel.send(Event.Network(event))
    }
}

launch {
    sensorManager.readings.collect { reading ->
        eventChannel.send(Event.Sensor(reading))
    }
}

launch {
    userInput.clicks.collect { click ->
        eventChannel.send(Event.UserAction(click))
    }
}

// Single consumer — processes events from all sources
launch {
    for (event in eventChannel) {
        when (event) {
            is Event.Network -> handleNetwork(event)
            is Event.Sensor -> handleSensor(event)
            is Event.UserAction -> handleUserAction(event)
        }
    }
}

select — Receiving from Multiple Channels

The select expression lets you receive from whichever channel has a value ready first:

val primaryChannel = Channel<String>()
val fallbackChannel = Channel<String>()

// Receive from whichever channel is ready first
val result = select {
    primaryChannel.onReceive { value ->
        "Primary: $value"
    }
    fallbackChannel.onReceive { value ->
        "Fallback: $value"
    }
}
println(result)

// Practical example — timeout with fallback
val dataChannel = Channel<Data>()

val result = select {
    dataChannel.onReceive { data ->
        Result.Success(data)
    }
    onTimeout(5000) {
        Result.Timeout
    }
}

Channel to Flow Conversion

You can convert between Channels and Flows when needed:

// Channel → Flow
val channel = Channel<Int>(Channel.BUFFERED)

val flow: Flow<Int> = channel.receiveAsFlow()
// Each value is delivered to ONE collector (same as channel semantics)
// If multiple collectors, values are distributed fan-out style

val flow2: Flow<Int> = channel.consumeAsFlow()
// Same but consumes the channel — only one collector allowed

// Flow → Channel
val channel2: ReceiveChannel<Int> = flowOf(1, 2, 3)
    .produceIn(coroutineScope)   // launches a coroutine to collect the Flow

receiveAsFlow vs consumeAsFlow

val channel = Channel<Int>(10)

// receiveAsFlow — multiple collectors share the channel (fan-out)
val flow = channel.receiveAsFlow()
launch { flow.collect { println("A: $it") } }
launch { flow.collect { println("B: $it") } }
// Values are split between A and B (each value goes to one)

// consumeAsFlow — only one collector, consumes the channel
val flow2 = channel.consumeAsFlow()
launch { flow2.collect { println("Only: $it") } }
// Second collector would throw IllegalStateException

Real Android Patterns

Work queue with multiple workers

class ImageProcessor(private val scope: CoroutineScope) {

    private val taskChannel = Channel<ImageTask>(capacity = 50)

    init {
        // Launch 3 worker coroutines
        repeat(3) { workerId ->
            scope.launch(Dispatchers.Default) {
                for (task in taskChannel) {
                    try {
                        val result = processImage(task)
                        task.callback(Result.success(result))
                    } catch (e: CancellationException) {
                        throw e
                    } catch (e: Exception) {
                        task.callback(Result.failure(e))
                    }
                }
            }
        }
    }

    suspend fun submit(task: ImageTask) {
        taskChannel.send(task)
    }

    fun shutdown() {
        taskChannel.close()
    }
}

Event queue in ViewModel

class OrderViewModel(private val repository: OrderRepository) : ViewModel() {

    // Channel for sequential event processing
    private val actionChannel = Channel<UserAction>(Channel.UNLIMITED)

    private val _uiState = MutableStateFlow(OrderUiState())
    val uiState: StateFlow<OrderUiState> = _uiState

    init {
        // Process actions one at a time, in order
        viewModelScope.launch {
            for (action in actionChannel) {
                when (action) {
                    is UserAction.AddItem -> handleAddItem(action.item)
                    is UserAction.RemoveItem -> handleRemoveItem(action.itemId)
                    is UserAction.PlaceOrder -> handlePlaceOrder()
                    is UserAction.ApplyDiscount -> handleApplyDiscount(action.code)
                }
            }
        }
    }

    // UI calls these — actions are queued and processed sequentially
    fun addItem(item: Item) {
        actionChannel.trySend(UserAction.AddItem(item))
    }

    fun removeItem(itemId: String) {
        actionChannel.trySend(UserAction.RemoveItem(itemId))
    }

    fun placeOrder() {
        actionChannel.trySend(UserAction.PlaceOrder)
    }

    private suspend fun handleAddItem(item: Item) {
        _uiState.update { state ->
            state.copy(items = state.items + item)
        }
        // Recalculate total from server
        val total = repository.calculateTotal(uiState.value.items)
        _uiState.update { it.copy(total = total) }
    }

    private suspend fun handlePlaceOrder() {
        _uiState.update { it.copy(isSubmitting = true) }
        try {
            repository.placeOrder(uiState.value.items)
            _uiState.update { it.copy(isSubmitting = false, orderPlaced = true) }
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            _uiState.update { it.copy(isSubmitting = false, error = e.message) }
        }
    }
}

Rate-limited API caller with Channel

class RateLimitedApi(
    private val api: Api,
    private val scope: CoroutineScope
) {
    private val requestChannel = Channel<ApiRequest>(Channel.UNLIMITED)

    init {
        scope.launch {
            for (request in requestChannel) {
                try {
                    val response = api.execute(request.call)
                    request.deferred.complete(response)
                } catch (e: CancellationException) {
                    throw e
                } catch (e: Exception) {
                    request.deferred.completeExceptionally(e)
                }
                delay(100)   // rate limit: max 10 requests per second
            }
        }
    }

    suspend fun <T> execute(call: suspend () -> T): T {
        val deferred = CompletableDeferred<T>()
        requestChannel.send(ApiRequest(call, deferred))
        return deferred.await()
    }
}

// Usage
val rateLimitedApi = RateLimitedApi(api, viewModelScope)

// These are queued and executed with 100ms gaps
val user = rateLimitedApi.execute { api.getUser() }
val orders = rateLimitedApi.execute { api.getOrders() }

Common Mistakes to Avoid

Mistake 1: Forgetting to close the channel

// ❌ Consumer hangs forever waiting for more values
val channel = Channel<Int>()

launch {
    channel.send(1)
    channel.send(2)
    // forgot channel.close()!
}

launch {
    for (value in channel) {   // loops forever — never finishes
        println(value)
    }
}

// ✅ Always close when done sending
launch {
    channel.send(1)
    channel.send(2)
    channel.close()   // consumer's for-loop exits
}

// ✅ Or use produce {} which auto-closes
val channel = produce {
    send(1)
    send(2)
}   // automatically closed when block finishes

Mistake 2: Using Channel when Flow is more appropriate

// ❌ Using Channel to expose data to UI
class MyViewModel : ViewModel() {
    val articles = Channel<List<Article>>()   // wrong tool!
    // Problems:
    // - No current value for new subscribers
    // - Must manually manage lifecycle
    // - Only one collector gets the data
}

// ✅ Use StateFlow for UI state
class MyViewModel : ViewModel() {
    val articles: StateFlow<List<Article>> = repository.getArticles()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}

Mistake 3: Using Channel.UNLIMITED without considering memory

// ❌ Unlimited buffer + slow consumer = OutOfMemoryError
val channel = Channel<LargeObject>(Channel.UNLIMITED)

launch {
    while (true) {
        channel.send(generateLargeObject())   // fills memory endlessly
    }
}

launch {
    for (obj in channel) {
        slowProcess(obj)   // consumer can't keep up
    }
}

// ✅ Use bounded buffer — producer suspends when buffer is full
val channel = Channel<LargeObject>(capacity = 10)

// ✅ Or use CONFLATED if only the latest matters
val channel = Channel<LargeObject>(Channel.CONFLATED)

Mistake 4: Not handling ClosedSendChannelException

// ❌ Crashes if channel is closed while trying to send
launch {
    while (true) {
        channel.send(produceValue())   // throws if channel was closed
    }
}

// ✅ Use trySend or handle the exception
launch {
    while (true) {
        val result = channel.trySend(produceValue())
        if (result.isFailure) break   // channel closed, stop producing
    }
}

// ✅ Or check isClosedForSend
launch {
    while (!channel.isClosedForSend) {
        channel.send(produceValue())
    }
}

Summary

  • A Channel is a hot, suspending queue for coroutine-to-coroutine communication
  • Each value is delivered to exactly one receiver — unlike Flow which broadcasts to all collectors
  • Rendezvous (default) has no buffer — sender and receiver meet at the same point
  • Buffered channels let the producer get ahead by a fixed amount
  • Unlimited channels never suspend the sender but risk OutOfMemoryError
  • Conflated channels keep only the latest value — good for status updates
  • produce {} is the preferred way to create channels — auto-closes when done
  • Fan-out: multiple consumers on one channel = work distribution
  • Fan-in: multiple producers on one channel = event merging
  • select lets you receive from whichever channel is ready first
  • Convert between Channel and Flow with receiveAsFlow() and produceIn()
  • Use Flow for UI state (StateFlow/SharedFlow) — use Channels for internal coroutine coordination
  • Always close channels when done — or use produce {} for automatic closing
  • Prefer bounded buffers over UNLIMITED to prevent memory issues

Channels are a lower-level primitive than Flow. Most Android developers will use Flow and StateFlow day-to-day and reach for Channels only in specific scenarios — work queues, rate limiting, fan-out processing, and coroutine coordination. But understanding Channels completes your mental model of Kotlin’s concurrency toolkit. Flow is built on top of Channels internally, so knowing how both work makes you a stronger Kotlin developer overall.

Happy coding!