Building High-Performance .NET Apps With C# Channels
Learn C# Channels in .NET 10 with simple examples. Pass data safely between producers and consumers and build fast, smooth, high-performance apps.
Imagine a busy tea stall near a railway station. One person makes cups of chai as fast as they can. Another person serves those cups to customers. The chai maker does not hand each cup directly into a customer's hands. That would be chaos. Instead, finished cups go onto a small tray on the counter. The maker puts cups on the tray. The server picks cups off the tray. The tray sits quietly in the middle and keeps everything calm and in order.
That tray is exactly what a C# Channel is. It sits between the code that makes data and the code that uses data, and it passes items safely from one side to the other.
In this guide you will learn what Channels are, why they make your .NET apps fast and smooth, and how to use them step by step. The examples target .NET 10 (LTS) and C# 14, but Channels have worked the same way since .NET Core 3.0, so older versions are fine too.
The problem Channels solve
In real apps, work rarely happens in one straight line. A web API might receive 500 requests in one second but only be able to send 50 emails per second. A file uploader might read data quickly but write to disk slowly. One part of your code is always faster than another part.
When you try to connect a fast part to a slow part by hand, things get messy:
- You share a list between threads, and two threads change it at once. Your data breaks.
- You add a
lockeverywhere to stay safe, and now everything is slow and tangled. - The fast side floods the slow side, memory fills up, and the app crashes.
A Channel fixes all three problems with one simple idea: a safe queue in the middle.
What is a Channel, really?
A Channel is a type in the System.Threading.Channels namespace. It is a first-in, first-out (FIFO) queue. The first item you put in is the first item that comes out, just like a real queue at a bank.
Every channel has two ends:
- Writer (
channel.Writer) — the producer side. This is where you put data in. - Reader (
channel.Reader) — the consumer side. This is where you take data out.
The clever part is that both ends are asynchronous. When the queue is empty, the reader does not waste the CPU spinning in a loop asking "anything yet? anything yet?". It quietly waits using await and wakes up only when an item arrives. When the queue is full (for a bounded channel), the writer waits too. Nothing is wasted.
The life of one item
Steps
Produce
Code creates a value
Write
WriteAsync puts it in
Queue
Item waits in FIFO order
Read
ReadAsync takes it out
Consume
Code uses the value
Your first channel
Let's pass numbers from a producer to a consumer. This is the "hello world" of Channels.
using System.Threading.Channels;
// 1. Create an unbounded channel that carries int values.
Channel<int> channel = Channel.CreateUnbounded<int>();
// 2. Producer: write 5 numbers, then say "I am done".
var producer = Task.Run(async () =>
{
for (int i = 1; i <= 5; i++)
{
await channel.Writer.WriteAsync(i);
Console.WriteLine($"Produced {i}");
await Task.Delay(200); // pretend work
}
channel.Writer.Complete(); // very important: signal the end
});
// 3. Consumer: read until the channel is completed and empty.
var consumer = Task.Run(async () =>
{
await foreach (int number in channel.Reader.ReadAllAsync())
{
Console.WriteLine($" Consumed {number}");
}
});
await Task.WhenAll(producer, consumer);
Console.WriteLine("All done!");A few things to notice:
WriteAsyncadds an item.ReadAllAsyncgives you a stream of items you can loop over withawait foreach.channel.Writer.Complete()tells the reader "no more items are coming". Without this, theawait foreachwould wait forever. This is the most common beginner mistake, so remember it.- The producer and consumer run at the same time. The chai maker and the server work together, not one after the other.
Bounded vs unbounded channels
This is the single most important choice you make with Channels. Get it right and your app stays healthy under load.
| Feature | Unbounded channel | Bounded channel |
|---|---|---|
| Capacity | Unlimited | A fixed maximum you choose |
| Memory risk | High — a fast producer can fill RAM | Low — size is capped |
| Backpressure | None | Yes — slows the producer down |
| Best for | Small, predictable bursts | Real production workloads |
| Created with | Channel.CreateUnbounded<T>() | Channel.CreateBounded<T>(capacity) |
An unbounded channel has no limit. It is simple, but dangerous. If your producer makes items faster than the consumer reads them, the queue grows and grows until your app runs out of memory and crashes.
A bounded channel has a maximum size, for example 100 items. When it is full, the writer must wait for the consumer to free up space. This natural "slow down, the queue is full" signal is called backpressure, and it is a feature, not a problem. It keeps a fast producer from drowning a slow consumer.
Rule of thumb: unless you have a very good reason, use a bounded channel in production.
var options = new BoundedChannelOptions(capacity: 100)
{
FullMode = BoundedChannelFullMode.Wait, // wait when full (safest)
SingleReader = true, // one consumer reads
SingleWriter = false // many producers may write
};
Channel<Order> channel = Channel.CreateBounded<Order>(options);What happens when a bounded channel is full?
When you create a bounded channel, you choose how it behaves once it hits its limit. This is set with BoundedChannelFullMode.
| FullMode | What it does when full | When to use it |
|---|---|---|
Wait | The writer waits for free space | Default and safest. Use when every item matters. |
DropWrite | Throws away the new item | Live data where losing a few is fine (e.g. sensor readings) |
DropOldest | Removes the oldest item to fit the new one | When the newest data is the most valuable |
DropNewest | Removes the most recent queued item | Rare; keep the older queued items instead |
SingleReader and SingleWriter: a free speed boost
When you create a channel, you can tell .NET how many readers and writers you plan to use:
SingleReader = truemeans only one task will ever read.SingleWriter = truemeans only one task will ever write.
If you promise this, the channel uses faster internal code because it does not need extra safety checks for multiple readers or writers. It is like telling the tea stall "only one server, only one maker" so they can skip a lot of coordination.
Only set these to true if it is actually true for your code. If you set SingleWriter = true and then write from two tasks, you will get bugs that are very hard to find.
A real example: an order processing pipeline
Picture an online shop during a festival sale. Orders pour in much faster than you can charge cards and send confirmation emails. A channel is perfect here. The web request quickly drops the order into the channel and returns to the customer. A background worker reads orders and processes them at a steady pace.
Here is the worker that reads from the channel. In ASP.NET Core you would run this inside a BackgroundService.
public class OrderWorker(Channel<Order> channel, ILogger<OrderWorker> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// ReadAllAsync streams every order until the channel completes.
await foreach (Order order in channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
await ChargeCardAsync(order, stoppingToken);
await SendEmailAsync(order, stoppingToken);
logger.LogInformation("Processed order {Id}", order.Id);
}
catch (Exception ex)
{
// One bad order must not stop the whole pipeline.
logger.LogError(ex, "Failed order {Id}", order.Id);
}
}
}
}And the endpoint that feeds it. Notice how quick it is, because the slow work happens later in the worker:
app.MapPost("/orders", async (Order order, Channel<Order> channel) =>
{
// If the bounded channel is full, this waits politely (backpressure).
await channel.Writer.WriteAsync(order);
return Results.Accepted($"/orders/{order.Id}");
});The user gets a fast response. The heavy lifting is smoothed out over time. The channel's bounded size protects your server from being overwhelmed.
Order pipeline at festival scale
Steps
Accept
API takes the order
Enqueue
WriteAsync to channel
Backpressure
Wait if queue is full
Process
Worker charges and ships
Notify
Email the customer
Many producers, many consumers
Channels really shine when you scale out. You can have several producers writing and several consumers reading from the same channel at once. The channel keeps everything thread-safe for you, with no locks in your own code.
A common pattern is to start a small pool of consumers so work gets done in parallel:
var channel = Channel.CreateBounded<WorkItem>(
new BoundedChannelOptions(200) { SingleReader = false, SingleWriter = false });
// Start 4 consumers that share the load.
var consumers = Enumerable.Range(0, 4).Select(id => Task.Run(async () =>
{
await foreach (WorkItem item in channel.Reader.ReadAllAsync())
{
await HandleAsync(item);
}
})).ToArray();
// Producers can write from anywhere, even in parallel.
await Parallel.ForEachAsync(incoming, async (item, ct) =>
{
await channel.Writer.WriteAsync(item, ct);
});
channel.Writer.Complete();
await Task.WhenAll(consumers);Because four consumers share one channel, the items get spread across them automatically. If one consumer is busy, another picks up the next item. This is a clean way to use all your CPU cores without writing any locking code yourself.
Completing and cancelling cleanly
Two signals keep a channel app tidy:
-
Completion — call
channel.Writer.Complete()when no more items will ever be written. This lets readers finish theirawait foreachloop and exit. You can also pass an exception toComplete(ex)to tell readers something went wrong. -
Cancellation — pass a
CancellationTokentoWriteAsyncandReadAllAsync. When the token is cancelled (for example, your app is shutting down), the waiting operations stop quickly instead of hanging.
try
{
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken))
{
await ProcessAsync(item);
}
}
catch (OperationCanceledException)
{
// Expected during shutdown. Nothing to worry about.
}There is also TryWrite and TryRead for cases where you do not want to wait at all. TryWrite returns false instead of waiting if a bounded channel is full, and TryRead returns false instead of waiting if the channel is empty. Use these when "skip it and move on" is better than waiting.
How Channels compare to other tools
You might wonder why not just use a ConcurrentQueue or BlockingCollection. Here is a quick comparison.
| Tool | Async support | Backpressure | Best fit |
|---|---|---|---|
Channel<T> | Yes, built-in await | Yes (bounded) | Modern async producer/consumer |
BlockingCollection<T> | No, blocks threads | Yes | Old code, sync workloads |
ConcurrentQueue<T> | No waiting at all | No | Simple shared queue, you poll it |
| TPL Dataflow | Yes | Yes | Complex multi-step graphs |
For everyday async work where one part of your code feeds another, Channel<T> is usually the simplest and fastest choice. BlockingCollection blocks real threads, which wastes resources in a web app. ConcurrentQueue has no built-in way to wait for an item, so you end up writing awkward polling loops.
A quick note for people who reach for libraries: heavy in-process messaging libraries like MediatR and MassTransit are now under commercial licenses for newer versions. For simple in-app producer/consumer work, a plain Channel<T> from the .NET base class library is free, built-in, and often all you need.
Common mistakes to avoid
- Forgetting
Complete(). Your reader loop will hang forever. Always complete the writer when you are done producing. - Using an unbounded channel under heavy load. Memory grows until the app dies. Prefer bounded.
- Lying about
SingleWriter/SingleReader. If you say single but use many, you get rare and confusing data bugs. - Letting one bad item crash the loop. Wrap your per-item processing in a
try/catchso one failure does not stop the whole pipeline. - Blocking with
.Resultor.Wait(). Alwaysawaitchannel operations so you do not freeze threads.
Quick recap
- A Channel is a safe FIFO queue that passes data from producers to consumers, like a tray between a chai maker and a server.
- The Writer end puts items in; the Reader end takes items out. Both work with
await, so no CPU is wasted waiting. - Prefer bounded channels in production. They cap memory and give you backpressure so a fast producer cannot drown a slow consumer.
- Pick a
BoundedChannelFullMode(Wait,DropWrite,DropOldest,DropNewest) based on whether every item matters. - Set
SingleReader/SingleWriteronly when truly correct, for a free speed boost. - Always call
Writer.Complete()when done, and pass aCancellationTokenfor clean shutdown. - You can run many producers and many consumers on one channel with zero manual locking.
References and further reading
- Channels - Microsoft Learn — the official documentation, with full API details.
- An Introduction to System.Threading.Channels - .NET Blog — a clear deep dive from the .NET team.
- An Introduction to System.Threading.Channels - Steve Gordon — a friendly community walkthrough with examples.
- How to use System.Threading.Channels in .NET Core - InfoWorld — practical patterns and tips.
Related Posts
Top 15 Mistakes .NET Developers Make and How to Avoid Common Pitfalls
Learn the 15 most common mistakes .NET developers make with async, EF Core, HttpClient, and memory, plus simple fixes you can use today.
How to Write Elegant Code with C# Pattern Matching
Learn C# pattern matching the easy way. Use is, switch expressions, property and list patterns to write clean, readable, and elegant .NET code.
How to Apply Functional Programming in C#: A Beginner's Guide
Learn functional programming in C# the simple way: pure functions, immutability, records, LINQ, pattern matching, and composition with friendly examples.
New Features in C# 13: A Friendly Beginner's Guide
Learn the new features in C# 13 with simple words, real-life examples, diagrams, and code you can read in minutes. Great for beginners.
Mastering Exception Handling in C#: A Comprehensive Guide
Learn C# exception handling the friendly way: try, catch, finally, custom exceptions, filters, throw vs throw ex, and real best practices for .NET 10.
Getting Started with C# Records: A Beginner's Friendly Guide
Learn C# records the easy way: value equality, with expressions, positional syntax, and record struct, explained with simple real-life examples.