Introduction
You've just built an API endpoint that receives incoming orders, and you need to hand them off to a background worker without blocking the request. Reach for System.Threading.Channels, and you'll have a fast, lightweight in-process queue running in microseconds. This deep dive into System.Threading.Channels: .NET best practices explained covers how to use bounded channels for producer/consumer patterns, when they shine, and when you need something more durable.
What System.Threading.Channels Does
Channels are a high-performance queue built into .NET (since .NET Core 3.0) for passing data between producers and consumers running on different threads or tasks. They're faster than BlockingCollection and vastly simpler than rolling your own queue with locks and events. Think of them as the Goldilocks of in-process messaging: fast enough for real work, structured enough to avoid classic threading bugs, and part of the standard library.
The core API is minimal. A channel has two halves:
- Writer — sends items into the channel.
- Reader — receives items from the channel.
Both support async-await, making them natural to use in modern .NET applications.
Bounded Channels Give You Backpressure for Free
Here's where bounded channels shine. When you create a channel with a capacity limit, the writer automatically suspends if the queue fills up. This is backpressure, and it's critical for stability.
var options = new BoundedChannelOptions(500)
{
FullMode = BoundedChannelFullMode.Wait
};
var channel = Channel.CreateBounded<Job>(options);
// In your API endpoint
async Task ReceiveJob(Job job, CancellationToken ct)
{
await channel.Writer.WriteAsync(job, ct);
return Ok("Job queued");
}
When the channel fills to 500 items, WriteAsync pauses. No error, no exception—just suspension. Your endpoint waits a moment, the worker processes something, and room opens up. The result: you never run out of memory, and you never drop work silently.
Compare that to an unbounded queue, which can grow without limit until your process runs out of memory and crashes. Or a worker thread that drops items when it can't keep up. Backpressure keeps producer and consumer in balance.
Choosing Full Mode
When the channel is full, you decide what happens next via BoundedChannelFullMode:
- Wait — WriteAsync blocks until space is available. Good for API endpoints; you can wait a little.
- DropOldest — oldest item is removed to make room. Risky; use only if you're okay losing old work (e.g., sensor readings).
- DropNewest — new item is discarded. Safer for real-time feeds but still means losing data.
- Reject — WriteAsync throws ChannelFullException. Forces you to handle overflow explicitly.
For durability and correctness, stick with Wait most of the time. It forces the producer to respect capacity and keeps everyone honest.
Perfect for an Endpoint That Hands Off Work to a Worker
The canonical use case: your endpoint receives a request, needs to do something long-running, but wants to return quickly. Queue the work and let a background task handle it.
// Hosted service that runs on startup
public class JobWorkerService : BackgroundService
{
private readonly Channel<Job> _channel;
private readonly ILogger<JobWorkerService> _logger;
public JobWorkerService(Channel<Job> channel, ILogger<JobWorkerService> logger)
{
_channel = channel;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var job in _channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
_logger.LogInformation("Processing job {JobId}", job.Id);
await ProcessJob(job, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Job {JobId} failed", job.Id);
// Decide: retry, deadletter, or log and move on
}
}
}
private async Task ProcessJob(Job job, CancellationToken ct)
{
// Simulate long-running work: call external API, heavy computation, etc.
await Task.Delay(5000, ct);
}
}
// Register in Program.cs
var channel = Channel.CreateBounded<Job>(500);
builder.Services.AddSingleton(channel);
builder.Services.AddHostedService<JobWorkerService>();
// Your endpoint
app.MapPost("/api/jobs", async (Job job, Channel<Job> channel, CancellationToken ct) =>
{
await channel.Writer.WriteAsync(job, ct);
return Results.Accepted();
});
The endpoint returns immediately with HTTP 202 (Accepted). The worker pulls items from the channel and processes them at its own pace. If traffic spikes, the channel fills up to 500, and new writes suspend, protecting your process from overload.
This pattern works beautifully for sending emails, generating reports, resizing images, or any I/O-bound work that doesn't need to block the request.
Not Durable — Lost on Restart
Here's the catch: channels live in process memory only. Restart your service, and everything in the channel is gone.
var channel = Channel.CreateBounded<Job>(500);
// Add 100 jobs
for (int i = 0; i < 100; i++)
{
await channel.Writer.WriteAsync(new Job { Id = i }, ct);
}
// Now crash the process, restart it...
// Those 100 jobs? Gone forever.
For many scenarios, this is fine. Queuing notifications, logging, or real-time metrics—if you lose a few, it's not a business disaster. But if every job matters, channels alone are insufficient.
Loss happens because channels have no disk persistence, no replication, and no crash recovery. They're designed for speed within a single process, not resilience across failures.
Need Durability? Use Kafka, RabbitMQ, or a DB Outbox
When every job must survive a crash, move to an external message broker or database pattern.
Message Broker (Kafka or RabbitMQ)
For high-throughput, distributed work:
// With RabbitMQ
await _bus.Publish(new JobCreatedEvent { JobId = job.Id, Data = job.Data });
// The bus persists the message to disk before acknowledging.
// Multiple consumers can pull from the same queue.
// Crashes don't lose messages.
Brokers are overkill if you need a simple local queue, but they shine when you have multiple services, need guaranteed delivery, or want retries built-in.
Database Outbox Pattern
For simpler scenarios or when you already have a database:
using var tx = await _db.BeginTransactionAsync(ct);
// Atomically insert both the order and the outbox event
await _db.Orders.AddAsync(order);
await _db.Outbox.AddAsync(new OutboxEvent
{
EventType = "OrderCreated",
Payload = JsonSerializer.Serialize(order),
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
// Background job processes outbox table
while (true)
{
var events = await _db.Outbox
.Where(e => !e.Processed)
.Take(100)
.ToListAsync(ct);
foreach (var evt in events)
{
// Send to external systems (email, API, etc.)
await Process(evt, ct);
evt.Processed = true;
}
await _db.SaveChangesAsync(ct);
}
The outbox pattern ensures that work is only marked complete after the database transaction succeeds. No work is lost because it's stored in a table with the same ACID guarantees as your main data.
Common Mistakes
Creating an Unbounded Channel
It's tempting to call Channel.CreateUnbounded<T>() and not worry about limits. But unbounded channels can grow to millions of items and crash your process. Use bounded channels and set a reasonable capacity.
Forgetting to Close the Writer
If you never call channel.Writer.Complete(), the reader's ReadAllAsync loop never terminates. Your hosted service runs forever, and the channel never signals "no more items coming."
// When shutting down or finishing work:
channel.Writer.Complete();
// Now the reader's loop exits gracefully
await foreach (var job in channel.Reader.ReadAllAsync(ct))
{
// This loop ends when Complete() is called and queue empties
}
Not Respecting Cancellation Tokens
Always pass CancellationToken to WriteAsync and ReadAllAsync. Without it, a graceful shutdown might hang if a channel operation is blocked.
// Good
await channel.Writer.WriteAsync(job, cancellationToken);
// Bad (might not cancel during shutdown)
await channel.Writer.WriteAsync(job);
Treating Channels as a Replacement for Durable Queues
Channels are brilliant for smoothing out short-term traffic spikes, but they're not a message bus. For jobs that must survive restarts or need to span multiple services, use a database or broker.
Key Takeaways
- Channels are fast, in-process queues for producer/consumer patterns; they're part of .NET and need no external dependencies.
- Bounded channels provide automatic backpressure, preventing memory exhaustion by suspending writes when full.
- Perfect for API endpoints that hand off work to background services (emails, reports, image processing) without blocking the response.
- Never durable — data in memory is lost on crash or restart; use channels for non-critical work only.
- For durability, reach for Kafka, RabbitMQ, or a database outbox pattern; they add persistence and guarantee delivery.
- Always close the writer and respect cancellation tokens to ensure graceful shutdown and avoid deadlocks.
- Bounded channels with FullMode.Wait are the safest default; they force producers to respect capacity and keep your system stable.






Comments
Loading comments…