Bounded and unbounded channels, full modes, single reader and writer options, completion, ReadAllAsync and backpressure in background processing.
System.Threading.Channels provides fast, async-friendly queues for passing data between producers and consumers. Channel.CreateBounded<T>(capacity) gives backpressure: when full, WriteAsync waits (or drops items, depending on BoundedChannelFullMode). Channel.CreateUnbounded<T>() never blocks writers but can grow without limit. Writers call Complete() when done; readers loop with await foreach (var x in reader.ReadAllAsync(ct)) and finish when the channel is completed and empty. Options such as SingleReader and SingleWriter enable faster internal implementations. Channels are the standard building block for in-process background queues and pipelines.
"
System.Threading.Channelsprovides fast, async-friendly in-memory queues for handing data from producers to consumers. A channel has a writer side and a reader side.Channel.CreateBounded<T>(capacity)gives backpressure: when it's full,WriteAsyncwaits, or items are dropped according toBoundedChannelFullMode, or I useTryWriteand reject the request.CreateUnbounded<T>()never makes writers wait, but it can grow until the process runs out of memory. The protocol: producers write and callWriter.Complete()when they're done (optionally with an exception). Consumers loop overReader.ReadAllAsync(ct)and finish once the channel is completed and drained. To build a pipeline, I chain stages with bounded channels between them, so a slow stage pushes back on faster ones instead of filling memory. Each stage gets its own number of consumers, and completion and errors are propagated from stage to stage. Channels are my default for background queues in ASP.NET Core, with two caveats: they're not durable, and ordering isn't preserved across multiple consumers."
Writer (ChannelWriter<T>) |
Reader (ChannelReader<T>) |
|---|---|
WriteAsync(item, ct): waits when full (bounded Wait mode) |
ReadAsync(ct): waits for an item |
TryWrite(item): false when full |
TryRead(out item): non-blocking |
WaitToWriteAsync(ct) |
WaitToReadAsync(ct): false once completed and empty |
Complete(exception?) / TryComplete |
ReadAllAsync(ct): IAsyncEnumerable<T> |
Completion: task that completes (or faults) when done and drained |
|
Count / CanCount (bounded channels support counting) |
| Bounded | Unbounded | |
|---|---|---|
| Memory | Capped by capacity | Grows without limit |
| Producer when consumers are slow | Waits / drops / rejects | Keeps writing |
| Use for | Anything fed by external traffic or with slow consumers | Producers you fully control with a known bounded rate |
BoundedChannelFullMode |
When full | Good for |
|---|---|---|
Wait |
WriteAsync waits; TryWrite returns false |
Internal producers; HTTP endpoints using TryWrite → 429/503 |
DropOldest |
Remove the oldest queued item | Telemetry, "latest value wins" |
DropNewest |
Remove the newest queued item | Rare |
DropWrite |
Discard the item being written | Best-effort events |
.NET 6+ supports an itemDropped callback on CreateBounded so drops can be counted and logged.
| Option | Effect |
|---|---|
SingleReader / SingleWriter |
Promises that enable faster internal implementations; breaking them is undefined behaviour |
AllowSynchronousContinuations |
Lets a reader continue on the writer's thread: faster, but the writer pays for the reader's work |
Capacity, FullMode |
Bounded channel shape |
| Decision | Typical answer |
|---|---|
| Capacity per stage | Enough to absorb bursts; small enough to bound memory |
| Consumers per stage | Match downstream capacity (DB connections, CPU cores, API limits) |
| Full behaviour at the edge | Wait for internal producers; reject (429/503) for HTTP; drop oldest for telemetry |
| Ordering | Partition by key into separate channels if per-key order matters |
| Completion | Each stage completes the next stage's writer when all its consumers finish |
| Errors | next.Writer.Complete(ex) so failures propagate instead of hanging |
| Shutdown | Stop writes, drain within the host's shutdown timeout |
| Observability | Queue depth, enqueue wait time, drops, processing time |
| Durability | Channels are in-memory: use a broker or outbox if loss is unacceptable |
Our [IoT ingestion API] wrote each device message straight to SQL, about [5,000 messages per second] in bursts. Steady load was fine, but bursts caused connection-pool exhaustion, database timeouts and API 500s. I put a bounded Channel<DeviceMessage> with capacity [10,000] in front of the database, read by [4] consumers that inserted in batches of [500] using SqlBulkCopy. The endpoint used TryWrite and returned 429 with Retry-After when the channel was full, so devices backed off instead of overwhelming the database. The 500s disappeared, database load became smooth, and channel depth became our key load metric, with an alert at [80%] capacity.
Checks modern producer/consumer design.
Green flags
Red flags
BlockingCollection<T>BlockingCollection<T> |
Channel<T> |
|
|---|---|---|
| Waiting | Blocks threads | Async (ValueTask), no thread held |
| Allocation per op | Higher | Very low (pooled async operations) |
| Completion semantics | CompleteAdding |
Complete(exception), Completion task |
| Fits ASP.NET Core / async code | Poorly | Well |
while (await reader.WaitToReadAsync(ct)) // false when completed and empty
{
while (reader.TryRead(out var item)) // drain what's available without awaiting
Process(item);
}
ReadAllAsync is fine for most code; the loop above avoids an await per item in hot paths and makes batching easy:
var batch = new List<Msg>(500);
while (await reader.WaitToReadAsync(ct))
{
while (batch.Count < 500 && reader.TryRead(out var m)) batch.Add(m);
await InsertBatchAsync(batch, ct);
batch.Clear();
}
static Task RunStageAsync<TIn, TOut>(ChannelReader<TIn> input, ChannelWriter<TOut> output,
int workers, Func<TIn, CancellationToken, ValueTask<TOut>> transform, CancellationToken ct)
{
var tasks = Enumerable.Range(0, workers).Select(async _ =>
{
await foreach (var item in input.ReadAllAsync(ct))
await output.WriteAsync(await transform(item, ct), ct);
}).ToArray();
return Task.WhenAll(tasks).ContinueWith(
t => output.TryComplete(t.Exception?.GetBaseException()),
CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
}
If any worker fails, the next stage's writer completes with the exception, its readers' Completion faults, and the failure surfaces at the end of the pipeline instead of everything hanging.
var partitions = Enumerable.Range(0, 8).Select(_ => Channel.CreateBounded<Evt>(1000)).ToArray();
int Partition(string key) => (int)((uint)key.GetHashCode() % (uint)partitions.Length);
await partitions[Partition(evt.AccountId)].Writer.WriteAsync(evt, ct);
// one consumer per partition -> per-account ordering preserved, 8-way parallelism overall
(Use a stable hash such as XxHash32 if partitioning must be consistent across processes.)
IValueTaskSource objects, so waiting doesn't allocate a Task.| Version | Change |
|---|---|
| .NET Core 2.1 (NuGet) / .NET Core 3.0 (in-box) | System.Threading.Channels |
| .NET Core 3.0 | ReadAllAsync |
| .NET 5 | Count/CanCount improvements |
| .NET 6 | itemDropped callback for bounded channels |
| .NET 9 | Unbounded prioritised channel (Channel.CreateUnboundedPrioritized) |
| .NET 10 | Same core API |
await foreach throws instead of hanging.DropOldest with an itemDropped callback and count drops.WaitToReadAsync / TryRead loop and measure DB round trips.using System.Threading.Channels;
var raw = Channel.CreateBounded<string>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait,
SingleWriter = true
});
var parsed = Channel.CreateBounded<int>(new BoundedChannelOptions(100) { SingleReader = true });
// Stage 1: producer
var producer = Task.Run(async () =>
{
for (int i = 0; i < 1_000; i++)
await raw.Writer.WriteAsync(i.ToString()); // waits when the channel is full
raw.Writer.Complete();
});
// Stage 2: four parsers in parallel
var parsers = Enumerable.Range(0, 4).Select(_ => Task.Run(async () =>
{
await foreach (var line in raw.Reader.ReadAllAsync())
await parsed.Writer.WriteAsync(int.Parse(line));
})).ToArray();
// Complete stage 3's input when all parsers finish (propagating failure if any).
_ = Task.WhenAll(parsers).ContinueWith(
t => parsed.Writer.Complete(t.Exception),
TaskScheduler.Default);
// Stage 3: single aggregator
long sum = 0;
await foreach (var n in parsed.Reader.ReadAllAsync())
sum += n;
await producer;
Console.WriteLine($"Sum {sum}"); // 499500
What happens: Channel.CreateUnbounded<T>() behind an HTTP endpoint.
Why it is wrong: slow consumers let memory grow until the process dies.
Do instead: bounded channels with a deliberate full mode.
Writer.Complete()What happens: producers finish but the channel is never completed.
Why it is wrong: ReadAllAsync never ends; shutdown hangs.
Do instead: complete when production ends, including on error (Complete(ex)).
SingleReader/SingleWriter promisesWhat happens: SingleReader = true but several consumers are started.
Why it is wrong: undefined behaviour: lost or duplicated items.
Do instead: set the flags only when strictly true.
What happens: four consumers process an account's events. Why it is wrong: events finish out of order. Do instead: partition by key, one consumer per partition.
What happens: a consumer task dies silently; others keep going or everything stalls. Why it is wrong: items stop being processed; the pipeline may hang. Do instead: per-item try/catch for recoverable errors; propagate fatal ones by completing the next stage with the exception.
WriteAsync with no limit on waiting in request handlersWhat happens: requests wait indefinitely when the channel is full.
Why it is wrong: request threads and connections pile up.
Do instead: TryWrite + 429/503, or WriteAsync with a short timeout token.
What happens: critical work (payments, emails) lives only in a channel. Why it is wrong: restarts and crashes lose it. Do instead: outbox or a message broker for must-not-lose work.
What happens: no visibility of backlog.
Why it is wrong: consumers falling behind go unnoticed until memory or latency explodes.
Do instead: export Reader.Count, wait times and drop counts.
AllowSynchronousContinuations casuallyWhat happens: set for speed. Why it is wrong: consumer work runs inline on producer threads (possibly request threads). Do instead: leave it off unless benchmarks justify it.
What happens: a single reader performs one DB insert per message.
Why it is wrong: throughput is capped by round-trip latency.
Do instead: batch with TryRead loops, or add consumers up to downstream capacity.
Channels are my default for in-process queues because they make backpressure explicit. The design questions are capacity, what to do when full, how many consumers, and how completion and errors flow.
| Decision | Typical answer |
|---|---|
| Bounded or not | Bounded for anything fed by external traffic |
| When full | Wait for internal producers, reject (429/503) for HTTP, drop oldest for telemetry |
| Consumers | Match downstream capacity |
| Ordering | Partition by key if needed |
| Durability | Channels are in memory; use a broker if loss is unacceptable |
Fire-and-forget means starting a task and not awaiting it, such as _ = SendEmailAsync(). The risks are: exceptions are never observed, scoped services like DbContext are disposed when the request ends (giving ObjectDisposedException), work is lost on shutdown or crash, there is no limit on how much work piles up, and HttpContext may be used after the request is gone. In ASP.NET Core, hand the work to a queue (Channel<T>) processed by a BackgroundService that creates its own scope, logs failures and drains on shutdown. If the work must not be lost, use a durable queue or an outbox table.
IAsyncEnumerable<T> is a sequence whose items arrive asynchronously. You produce one with an async iterator method that uses both await and yield return, and consume it with await foreach, which awaits MoveNextAsync() for each item and disposes the enumerator asynchronously. Cancellation is passed with .WithCancellation(ct) and received in the iterator through a parameter marked [EnumeratorCancellation]. It lets you process large or slow data item by item instead of buffering everything, for example EF Core AsAsyncEnumerable() or streaming JSON responses from ASP.NET Core. .NET 10 includes LINQ operators for it in the base library (System.Linq.AsyncEnumerable).
TPL Dataflow (System.Threading.Tasks.Dataflow, a NuGet package) is a library of blocks you link into a pipeline: BufferBlock<T>, TransformBlock<TIn,TOut>, TransformManyBlock, BatchBlock<T>, ActionBlock<T>, BroadcastBlock<T> and join blocks. Each block has its own buffering, parallelism (MaxDegreeOfParallelism) and capacity (BoundedCapacity), and LinkTo with PropagateCompletion = true flows completion and errors through. I choose it for multi-stage pipelines with different parallelism per stage, batching or fan-out, where writing the same with channels would need a lot of plumbing. For a single stage, Parallel.ForEachAsync or a channel is simpler.
How to tell it (STAR)
Our [ingestion API] failed during bursts because every message was a separate insert. I put a bounded channel in front of the database with a few batching consumers, and returned 429 when the channel was full so devices retried. The 500s disappeared and channel depth became our key load metric.
Last updated 31 Oct 2026.