Skip to main content
SEMastery
.NET Coreintermediate

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.

12 min readUpdated December 9, 2025

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 lock everywhere 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.

A producer and a consumer never touch each other directly. The channel sits in between.

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

Produce
Write
Queue
Read
Consume

Steps

1

Produce

Code creates a value

2

Write

WriteAsync puts it in

3

Queue

Item waits in FIFO order

4

Read

ReadAsync takes it out

5

Consume

Code uses the value

How a single piece of data travels through a channel from start to finish.

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:

  • WriteAsync adds an item. ReadAllAsync gives you a stream of items you can loop over with await foreach.
  • channel.Writer.Complete() tells the reader "no more items are coming". Without this, the await foreach would 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.

FeatureUnbounded channelBounded channel
CapacityUnlimitedA fixed maximum you choose
Memory riskHigh — a fast producer can fill RAMLow — size is capped
BackpressureNoneYes — slows the producer down
Best forSmall, predictable burstsReal production workloads
Created withChannel.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.

FullModeWhat it does when fullWhen to use it
WaitThe writer waits for free spaceDefault and safest. Use when every item matters.
DropWriteThrows away the new itemLive data where losing a few is fine (e.g. sensor readings)
DropOldestRemoves the oldest item to fit the new oneWhen the newest data is the most valuable
DropNewestRemoves the most recent queued itemRare; keep the older queued items instead
Choosing a FullMode decides what a full bounded channel does with an arriving item.

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 = true means only one task will ever read.
  • SingleWriter = true means 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.

The web layer writes orders fast; a background worker reads and processes them at a safe 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

Accept
Enqueue
Backpressure
Process
Notify

Steps

1

Accept

API takes the order

2

Enqueue

WriteAsync to channel

3

Backpressure

Wait if queue is full

4

Process

Worker charges and ships

5

Notify

Email the customer

How thousands of orders stay under control with a bounded channel and a steady worker.

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:

  1. Completion — call channel.Writer.Complete() when no more items will ever be written. This lets readers finish their await foreach loop and exit. You can also pass an exception to Complete(ex) to tell readers something went wrong.

  2. Cancellation — pass a CancellationToken to WriteAsync and ReadAllAsync. 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.

ToolAsync supportBackpressureBest fit
Channel<T>Yes, built-in awaitYes (bounded)Modern async producer/consumer
BlockingCollection<T>No, blocks threadsYesOld code, sync workloads
ConcurrentQueue<T>No waiting at allNoSimple shared queue, you poll it
TPL DataflowYesYesComplex 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/catch so one failure does not stop the whole pipeline.
  • Blocking with .Result or .Wait(). Always await channel 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 / SingleWriter only when truly correct, for a free speed boost.
  • Always call Writer.Complete() when done, and pass a CancellationToken for clean shutdown.
  • You can run many producers and many consumers on one channel with zero manual locking.

References and further reading

Related Posts