Many applications need to perform work outside the main request pipeline. Sending emails, processing orders, generating reports, resizing images, and publishing events are common examples of background tasks that shouldn't delay API responses.
A common mistake is to execute these operations directly within an HTTP request, increasing response times and reducing scalability. Another approach is using BlockingCollection, which relies on blocking operations and is less suited to modern asynchronous applications.
System.Threading.Channels provides a high-performance, asynchronous producer-consumer implementation that enables efficient background processing with minimal locking and excellent scalability.
In this article, you'll learn how to build a production-ready background processing system using Channels, compare it with other approaches, and follow best practices for reliable asynchronous processing.
What Are Channels?
A Channel is an asynchronous queue designed for communication between producers and consumers.
Producer writes items to the channel.
Consumer reads items asynchronously.
Multiple producers and consumers are supported.
A simplified architecture looks like this:
HTTP Request
│
▼
Write Job to Channel
│
▼
--------------------
| Channel |
--------------------
│
▼
Background Worker
│
▼
Process Job
The API request completes quickly while the background worker processes the task independently.
Why Use Channels?
Channels offer several advantages:
Asynchronous producer-consumer model
High throughput
Low memory overhead
Built-in backpressure
Thread-safe operations
Multiple producers and consumers
Excellent integration with
BackgroundService
They are ideal for workloads where requests should be acknowledged immediately while processing continues in the background.
Creating a Channel
Create an unbounded channel.
using System.Threading.Channels;
var channel = Channel.CreateUnbounded<string>();
For production systems, bounded channels are usually preferred to prevent unbounded memory growth.
Creating a Bounded Channel
var channel = Channel.CreateBounded<string>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait
});
Why Use a Bounded Channel?
A bounded channel:
Limits memory usage
Applies backpressure
Prevents unlimited queue growth
Protects the application during traffic spikes
Writing to the Channel
A producer writes work items asynchronously.
await channel.Writer.WriteAsync(
"Process Order");
The producer does not need to know how or when the item will be processed.
Reading from the Channel
Consumers process items asynchronously.
await foreach (var job in
channel.Reader.ReadAllAsync())
{
Console.WriteLine(job);
}
The loop continues until the channel is completed.
Creating a Background Worker
A production application typically processes the channel inside a hosted service.
public class OrderProcessor
: BackgroundService
{
private readonly Channel<string> _channel;
public OrderProcessor(Channel<string> channel)
{
_channel = channel;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
await foreach (var order in
_channel.Reader.ReadAllAsync(stoppingToken))
{
Console.WriteLine(
$"Processing {order}");
}
}
}
The background worker continuously processes queued items without blocking incoming requests.
Register the Services
builder.Services.AddSingleton(
Channel.CreateBounded<string>(100));
builder.Services.AddHostedService<OrderProcessor>();
The channel is shared between producers and consumers through dependency injection.
Queue Work from an API
app.MapPost("/orders", async (
Channel<string> channel) =>
{
await channel.Writer.WriteAsync(
Guid.NewGuid().ToString());
return Results.Accepted();
});
The endpoint immediately returns 202 Accepted, while the background worker handles processing asynchronously.
Handling Multiple Consumers
Channels support multiple background workers.
builder.Services.AddHostedService<OrderProcessor>();
builder.Services.AddHostedService<OrderProcessor>();
Each worker processes different items from the same queue, improving throughput for parallel workloads.
Graceful Shutdown
Complete the channel when the application stops.
channel.Writer.Complete();
Consumers finish processing remaining work before exiting, helping prevent data loss during shutdown.
End-to-End Processing Flow
A typical request follows these steps:
Client submits a request.
API validates the request.
Work item is written to the channel.
API immediately returns a response.
Background worker reads the item.
Business logic executes.
Processing completes independently of the original request.
This pattern keeps request latency low while supporting high throughput.
Join the conversation! Your thoughts help the community grow.