If producers enqueue work faster than a worker processes it, an unbounded queue keeps growing in memory. Use a bounded Channel<T> to cap the buffer and decide what producers should do when it fills.
Set a capacity and a waiting policy
For a document worker, queue identifiers rather than complete document contents. Create one channel and share it between producers and the consumer in the application process:
using System.Threading.Channels;
var queue = Channel.CreateBounded<Guid>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = false
});
// In a producer, while the worker runs independently:
await queue.Writer.WriteAsync(documentId, cancellationToken);
The capacity of 100 is an example. Choose a limit based on how long work can wait and how much memory each item retains. SingleReader = true promises at most one read operation at a time. The channel does not enforce that restriction. SingleWriter = false allows concurrent producers.
With Wait, WriteAsync waits asynchronously for space when the buffer is full. Await the write. If the producer must not wait indefinitely, pass a cancellation token tied to an appropriate timeout or deadline. Launching unawaited writes only moves the backlog into pending operations.
If a producer cannot wait, use TryWrite with the same Wait policy. It returns false immediately when the buffer is full or the writer has completed. Check that result: reject the submission or count it as deliberately skipped work. The production-evaluation tip shows a case where skipping work is acceptable.
The DropWrite, DropOldest, and DropNewest modes discard items by design. Use them only when losing work is acceptable. A successful write under a drop policy does not guarantee that the submitted item will be processed.
Keep processing bounded too
The worker can consume sequentially:
await foreach (var documentId in queue.Reader.ReadAllAsync(stoppingToken))
{
await ProcessDocumentAsync(documentId, stoppingToken);
}
ProcessDocumentAsync is your application handler. Put this loop in a BackgroundService with a failure and shutdown policy. By default, an unhandled exception escaping ExecuteAsync stops the host. Decide whether to retry failed work, record and skip it, or let the exception stop the host. Await each handler call. Starting a task for every dequeued item removes the processing limit even though the buffer stays bounded.
Capacity counts buffered items, excluding work already being processed and producers waiting to write. It does not bound total application memory or increase worker throughput.
In a BackgroundService, calling StopAsync cancels stoppingToken. The loop above passes that token to both the reader and the handler, so shutdown can leave buffered work unfinished. To drain the queue, stop accepting submissions, call queue.Writer.TryComplete(), and give the consumer until a separate shutdown deadline to finish the remaining items. Reading and processing during the drain need a token tied to that deadline instead of stoppingToken. That requires separate shutdown coordination beyond the loop shown here.
When to use it
Use a bounded channel for process-local work that is disposable or recoverable from persistent state. If accepted work must survive a process restart or move between replicas, use a durable background-job design.
Sources
- Microsoft Learn: Channels in .NET
- Microsoft Learn: BoundedChannelOptions and capacity
- Microsoft Learn: Background tasks with hosted services
- Microsoft Learn: BackgroundService.ExecuteAsync and its stopping token
- Microsoft Learn: Default background-service exception behavior
- Microsoft Learn: ChannelWriter.TryComplete