System.Threading.Channels: The Async Pipeline Primitive

You have an API endpoint that receives work. You have a background service that processes it. You need a thread-safe, async-friendly way to pass items between them without blocking threads or losing data. BlockingCollection<T> blocks. ConcurrentQueue<T> requires polling. Channel<T> does exactly what you want: async writes, async reads, optional backpressure, and zero thread blocking.

The Pattern

using System.Threading.Channels;
// Create a bounded channel: if 100 items are queued, writers wait
var channel = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait, // writers await until space is available
SingleReader = true // optimization hint for single consumer
});

The Producer (API Endpoint)

app.MapPost("/ingest", async (WorkItem item, Channel<WorkItem> channel) =>
{
await channel.Writer.WriteAsync(item);
return Results.Accepted();
});

WriteAsync completes instantly if there’s space. If the channel is full, it asynchronously waits — no thread is blocked, no request is lost.

The Consumer (Background Service)

public class WorkItemProcessor(
Channel<WorkItem> channel,
ILogger<WorkItemProcessor> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var item in channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
logger.LogInformation("Processing {Id}", item.Id);
await ProcessAsync(item, stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process {Id}", item.Id);
}
}
logger.LogInformation("Channel closed. Shutting down.");
}
private async Task ProcessAsync(WorkItem item, CancellationToken ct)
{
// Simulate work
await Task.Delay(100, ct);
}
}

ReadAllAsync returns an IAsyncEnumerable that yields items as they arrive. When the writer calls channel.Writer.Complete(), the loop exits gracefully.

Registration

var channel = Channel.CreateBounded<WorkItem>(100);
builder.Services.AddSingleton(channel);
builder.Services.AddSingleton(channel.Reader);
builder.Services.AddSingleton(channel.Writer);
builder.Services.AddHostedService<WorkItemProcessor>();

Bounded vs. Unbounded

TypeBehaviorUse When
CreateBounded<T>(n)Writers wait when fullYou need backpressure (most cases)
CreateUnbounded<T>()Never blocks writersYou trust producers won’t overwhelm memory

When the Channel is Full

BoundedChannelFullMode controls what happens:

FullMode = BoundedChannelFullMode.Wait // Writer awaits (default, safest)
FullMode = BoundedChannelFullMode.DropOldest // Discard oldest item, write new one
FullMode = BoundedChannelFullMode.DropNewest // Discard the item being written
FullMode = BoundedChannelFullMode.DropWrite // Same as DropNewest

Key Takeaway

Channel<T> is the async-native producer/consumer primitive. Bounded channels give you backpressure without blocking threads. Pair it with a BackgroundService consumer and you have a robust in-process pipeline in about 30 lines. If you’re using ConcurrentQueue + polling or BlockingCollection + thread waste, this is the upgrade.