Bounded Channels and Backpressure
A bounded channel makes a fast producer wait for a slow consumer, which caps memory instead of letting the queue grow.
Channel<T> channel = Channel.CreateBounded<T>(capacity); // or CreateBounded<T>(BoundedChannelOptions)Why a limit helps
An unbounded channel accepts items as fast as the producer writes them. If the consumer is slower, the queue grows. Nothing complains and no error appears. Then the process runs out of memory, usually in production and usually at the worst moment.
A bounded channel has a fixed capacity. Channel.CreateBounded<int>(100) holds at most a hundred waiting items. When it is full, WriteAsync neither fails nor grows the queue. It waits. The producer stays suspended until a reader takes an item out. That is called backpressure: the slow end of the system reaches back and slows the fast end down.
Choosing what full means
Pass a BoundedChannelOptions to control it. Its FullMode property takes one of four values.
| FullMode | What a write does when the channel is full |
|---|---|
Wait | Waits for room. This is the default. |
DropOldest | Discards the oldest stored item to make room. |
DropNewest | Discards the newest stored item to make room. |
DropWrite | Discards the item being written. |
Waiting is right for work that must all be done. Dropping is right when only fresh data matters, such as the latest reading from a sensor.
TryWrite against a full channel
TryWrite never waits. In Wait mode it returns false on a full channel and the item is not stored. That return value is how you shed load on purpose: refuse the request rather than queue it.
What trips people up
Capacity is not a speed setting. A larger capacity only buys a longer burst; it does not make the consumer faster.
Example
using System.Threading.Channels;
// Capacity 1: the channel holds one waiting item at a time.
Channel<string> channel = Channel.CreateBounded<string>(1);
await channel.Writer.WriteAsync("first"); // the slot was free, so this returns at once
// The slot is taken now, so this write cannot finish until a reader takes something out.
// AsTask gives a Task we can inspect without awaiting it yet.
Task second = channel.Writer.WriteAsync("second").AsTask();
Console.WriteLine($"second write waiting: {!second.IsCompleted}");
string taken = await channel.Reader.ReadAsync();
Console.WriteLine($"read {taken}");
await second; // the slot is free, so the waiting write completes
Console.WriteLine($"second write waiting: {!second.IsCompleted}");
channel.Writer.Complete();
await foreach (string item in channel.Reader.ReadAllAsync())
{
Console.WriteLine($"drained {item}");
}
// Output:
// second write waiting: True
// read first
// second write waiting: False
// drained second
When to use it
- Cap how many unprocessed webhook payloads your service holds in memory at once.
- Let a slow database writer slow down the loop that is reading a large CSV file.
- Keep only the newest sensor reading and discard stale ones with DropOldest.
- Reject an incoming job instead of queueing it, by acting on a false return from TryWrite.
More examples
Keeping only the latest reading
Reach for DropOldest when an old item has no value once a newer one exists: a temperature reading, a cursor position, a progress percentage. SingleWriter and SingleReader are promises you make to the channel, not requests. Setting SingleWriter to true means at most one write happens at a time, which lets the channel use a cheaper internal path; breaking that promise by writing from two places at once corrupts the channel.
using System.Threading.Channels;
// Wait is the default FullMode. DropOldest discards stale items instead.
BoundedChannelOptions options = new BoundedChannelOptions(2)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleWriter = true,
SingleReader = true
};
// new BoundedChannelOptions(2) and new(2) mean the same thing here;
// the short form works because the target type is already known.
Channel<int> latest = Channel.CreateBounded<int>(options);
for (int i = 1; i <= 5; i++)
{
// Always succeeds: the oldest stored item is evicted to make room.
latest.Writer.TryWrite(i);
}
latest.Writer.Complete();
await foreach (int value in latest.Reader.ReadAllAsync())
{
Console.WriteLine(value);
}
// Output:
// 4
// 5
Seeing what was dropped
A dropping channel loses data on purpose, so you need a way to count what it lost. The callback runs on the thread that did the write, so keep it to incrementing a counter or writing a log line, never to real work. Without this callback a dropping channel is invisible: TryWrite returns true whether the item was stored or discarded.
using System.Threading.Channels;
BoundedChannelOptions options = new BoundedChannelOptions(1)
{
FullMode = BoundedChannelFullMode.DropWrite
};
// The second argument runs for every item the channel throws away.
// This overload of CreateBounded was added in .NET 6.
Channel<string> channel = Channel.CreateBounded<string>(
options,
dropped => Console.WriteLine($"dropped: {dropped}"));
channel.Writer.TryWrite("keep");
channel.Writer.TryWrite("lose"); // full, so this item is the one discarded
channel.Writer.Complete();
await foreach (string item in channel.Reader.ReadAllAsync())
{
Console.WriteLine($"kept: {item}");
}
// Output:
// dropped: lose
// kept: keep
Discussion