Kotlin Coroutines provide several powerful concurrency primitives, but when you need direct communication between coroutines, Channels become the tool of choice. While Flow is excellent for reactive streams and UI/data pipelines, channels are designed to solve a different category of problems:
- sending messages between coroutines
- coordinating concurrent workers
- building producer/consumer pipelines
- creating job queues
- implementing actor-style concurrency models
Channels allow you to build concurrency systems with controlled backpressure, buffering, rendezvous points, and multiple producers or consumers. They are heavily inspired by the concept of CSP (Communicating Sequential Processes), which also appears in languages like Go.
In this article, you’ll learn:
- What channels are
- Channel types: Rendezvous, Conflated, Buffered
- Producer/consumer patterns
- Fan-out & fan-in concurrency patterns
- When Flow is preferable over Channels
- A real example: building a job queue using channels
Let’s begin with the basics.
What Are Channels?
A Channel is a coroutine-safe communication primitive that allows one coroutine to send values to another coroutine. It behaves similarly to a thread-safe queue, except tailored for coroutines.
Channels provide two main operations:
send(value)
Suspends until the receiver is ready (depending on buffer type).
receive()
Suspends until a value is available.
You can think of channels like pipes:
Producer ---> Channel ---> Consumer
Very simple example
val channel = Channel<Int>()
launch {
for (i in 1..5) {
channel.send(i)
println("Sent $i")
}
channel.close()
}
launch {
for (x in channel) {
println("Received $x")
}
}
Output:
Sent 1
Received 1
Sent 2
Received 2
...
The producer sends values, while the consumer receives them asynchronously.
Channel Types: Rendezvous, Conflated, Buffered
Kotlin provides multiple channel configurations, each suited for different concurrency patterns.
1. Rendezvous Channel (default)
Declaration:
val channel = Channel<Int>()
This channel has zero buffer.
Meaning:
send()suspends untilreceive()happensreceive()suspends until a value is available
This pattern forces synchronization between the two coroutines.
When to use Rendezvous channels
- when you want strong backpressure
- when the producer should not get ahead of the consumer
- tightly synchronized tasks
2. Buffered Channel
val channel = Channel<Int>(capacity = 10)
Buffered channels can store values in a queue:
- sender suspends only when buffer is full
- receiver suspends only when buffer is empty
Example:
val channel = Channel<Int>(3)
launch {
repeat(5) {
println("Sending $it")
channel.send(it)
println("Sent $it")
}
channel.close()
}
Output:
Sending 0
Sent 0
Sending 1
Sent 1
Sending 2
Sent 2
Sending 3 <-- suspends here until consumer receives something
When to use buffered channels
- pipelines
- slowing down producer
- smoothing temporary spikes in work
3. Conflated Channel
val channel = Channel<Int>(Channel.CONFLATED)
A conflated channel keeps only the latest value.
If the producer sends values faster than consumer receives them:
- older values are dropped
- only the newest is kept
Similar to: StateFlow or LiveData
Example:
Producer sends: 1, 2, 3, 4
Consumer receives: 4
When to use
- UI rendering
- sensors or data streams where only the latest matters
- push-based updates
Producer/Consumer Patterns
Channels are ideal for producer/consumer architectures.
Single Producer → Single Consumer
The simplest case:
val channel = Channel<Int>()
launch {
for (i in 1..5) {
channel.send(i)
}
channel.close()
}
launch {
for (value in channel) {
println("Consumed $value")
}
}
Multiple Producers → Single Consumer
Both producers can send into the same channel:
val channel = Channel<Int>()
launch {
repeat(5) { channel.send(it) }
}
launch {
repeat(5) { channel.send(it + 100) }
}
launch {
for (value in channel) {
println("Received $value")
}
}
Use cases:
- merging two data sources
- collecting logs from different modules
Single Producer → Multiple Consumers
This is a fan-out pattern.
Multiple consumers compete for jobs:
val channel = Channel<Job>()
repeat(3) { workerId ->
launch {
for (job in channel) {
println("Worker $workerId processed $job")
}
}
}
Producers feed jobs, workers process them in parallel.
Fan-Out & Fan-In Patterns
These are classic concurrency patterns used in high-performance systems.
Fan-Out
One producer → many consumers
Consumers share the workload.
Used for:
- parallel processing
- CPU-bound tasks
- upload/download workers
- image processing pipelines
Fan-In
Many producers → one consumer
Consumer merges results.
Used for:
- gathering logs
- merging data from multiple sources
- aggregating results
Combined pipeline example
Producer → Channel → Worker A
→ Worker B
→ Worker C → Results Channel → Aggregator →
Channels let you build these systems easily and safely.
When to Prefer Flow Over Channels
This is a critical question.
Use Flow when:
- you have a stream of data
- consumers only need to listen
- processing is declarative (map, filter, etc.)
- you want cold streams
- UI-level reactive pipelines
Use SharedFlow/StateFlow when:
- you need hot streams
- you have UI state or events
Use Channels when:
- you need two-way communication
- you need push mechanics (send/receive)
- you need backpressure control
- you coordinate workers
- you’re implementing actor models
- you build job queues
Channels = communication
Flows = data processing
Example: Building a Job Queue Using Channels
Let’s build a real example:
- Producer generates jobs
- Multiple consumers process them
- The queue uses channels for backpressure and synchronization
Step 1: Define a Job Type
data class Job(val id: Int, val payload: String)
Step 2: Create the channel
Use a buffered channel to queue incoming jobs:
val jobChannel = Channel<Job>(capacity = 10)
This means:
- producer can get ahead up to 10 jobs
- after that, producer suspends until a consumer takes a job
Step 3: Producer coroutine
val producer = launch {
repeat(50) { id ->
val job = Job(id, "Data $id")
println("Producing job $id")
jobChannel.send(job) // suspends if buffer is full
}
jobChannel.close() // no more jobs
}
Why close?
To signal to consumers that processing is complete.
Step 4: Consumer workers (fan-out)
repeat(3) { workerId ->
launch {
for (job in jobChannel) {
println("Worker $workerId processing ${job.id}")
delay(200) // simulate work
}
println("Worker $workerId done")
}
}
Workers consume until channel closes.
Step 5: Wait for everything
producer.join()
coroutineContext[Job]?.children?.forEach { it.join() }
Output example
Producing job 0
Producing job 1
Worker 0 processing 0
Worker 1 processing 1
Worker 2 processing 2
Producing job 3
Producing job 4
...
Worker 0 done
Worker 1 done
Worker 2 done
Here you see:
- multiple consumers pulling from a single channel (fan-out)
- producer generating work at its own pace
- natural backpressure from the channel buffer
- all jobs processed exactly once
Why Channels Are Useful Here
Channels shine in this use case because:
- producer/consumer coordination is automatic
- no locking or shared state needed
- backpressure is built-in
- concurrency is structured and predictable
Flows and SharedFlows cannot implement job queues cleanly, because:
- Flow is one-way only (producer → consumer)
- SharedFlow does not provide a direct “queue” model
- both lack explicit send/receive semantics
Channels were designed specifically for this type of coordination.
Conclusion
Channels are a powerful concurrency primitive in Kotlin. They provide structured, coroutine-safe communication between producers and consumers, enabling advanced patterns like worker pools, job queues, fan-in/fan-out pipelines, and actor models.
You learned:
- What channels are and how they work
- Types: Rendezvous, Buffered, Conflated
- Producer/consumer and worker patterns
- Fan-out and fan-in concurrency
- When to use Flow vs Channels
- How to build a real job queue using channels
Key takeaways
- Flow is declarative, channels are imperative
- Use channels for communication and coordination
- Buffered channels enable backpressure smoothing
- Conflated channels are great for UI-like “latest-only” semantics
- Channels integrate perfectly with coroutines
- They are essential for backend systems, job schedulers, and parallel pipelines
