System.Threading.Channels provides a high-performance, async-native producer-consumer queue. A controller action can write a work item to a channel without blocking, and a background service reads and processes items asynchronously. This decouples HTTP request latency from background processing time — the API returns 202 Accepted immediately while the background service processes the work. Channels replace both BlockingCollection<T> (synchronous) and third-party in-memory queues with a built-in, allocation-efficient, fully async solution.
Channel Setup and Usage
// ── Define the work item ───────────────────────────────────────────────────
public record EmailJob(string To, string Subject, string Body);
// ── Create and register the channel as a Singleton ────────────────────────
// Bounded channel: max 100 pending items — provides back-pressure
var channel = Channel.CreateBounded<EmailJob>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait // producer waits if channel is full
});
builder.Services.AddSingleton(channel.Reader); // consumer gets reader
builder.Services.AddSingleton(channel.Writer); // producer gets writer
// ── Producer: controller writes to the channel ────────────────────────────
[ApiController]
[Route("api/notifications")]
public class NotificationsController(
ChannelWriter<EmailJob> writer,
ILogger<NotificationsController> logger) : ControllerBase
{
[HttpPost("send-email")]
public async Task<IActionResult> SendEmail(
[FromBody] SendEmailRequest request,
CancellationToken ct)
{
var job = new EmailJob(request.To, request.Subject, request.Body);
// TryWrite returns immediately (non-blocking if channel has space)
if (writer.TryWrite(job))
return Accepted(new { message = "Email queued for delivery." });
// If channel is full: await with back-pressure (or return 429)
await writer.WriteAsync(job, ct);
return Accepted(new { message = "Email queued for delivery." });
}
}
Channel.CreateUnbounded<T>()) accept items without limit and never block the producer — useful for low-volume, bursty work where queue depth is not a concern. Bounded channels (Channel.CreateBounded<T>(capacity)) apply back-pressure: when full, producers must wait. For HTTP endpoints, back-pressure is important — without it, a traffic spike could fill the channel with thousands of jobs that overwhelm the background worker, consuming memory until the process crashes. A bounded channel with a reasonable capacity and FullMode.Wait (or DropOldest for non-critical work) prevents runaway memory growth.Channel<T> directly — inject ChannelWriter<T> in producers and ChannelReader<T> in consumers. This enforces the single-writer/single-reader principle at the type level and prevents background services from accidentally writing to the channel (or controllers from accidentally reading from it). Register channel.Reader and channel.Writer separately in DI as shown above.Consumer: BackgroundService Reads from Channel
// ── Consumer: background service reads and processes ──────────────────────
public class EmailDispatchService(
ChannelReader<EmailJob> reader,
IServiceScopeFactory scopeFactory,
ILogger<EmailDispatchService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Email dispatch service starting.");
// ReadAllAsync: returns IAsyncEnumerable — yields items as they arrive
// Completes when the channel is marked complete (writer.Complete())
await foreach (var job in reader.ReadAllAsync(stoppingToken))
{
try
{
await SendEmailAsync(job, stoppingToken);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(ex, "Failed to send email to {To} — job dropped.", job.To);
// For reliable delivery: push to a dead-letter queue instead of dropping
}
}
logger.LogInformation("Email dispatch service stopped.");
}
private async Task SendEmailAsync(EmailJob job, CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
await sender.SendAsync(job.To, job.Subject, job.Body, ct);
logger.LogInformation("Email sent to {To}.", job.To);
}
}
builder.Services.AddHostedService<EmailDispatchService>();
Common Mistakes
Mistake 1 — Injecting Channel<T> instead of ChannelWriter/ChannelReader
❌ Wrong — any service can access both read and write ends:
builder.Services.AddSingleton(channel); // exposes both Reader and Writer
✅ Correct — register Reader and Writer separately to enforce direction.
Mistake 2 — Using an unbounded channel for high-volume work (memory exhaustion)
❌ Wrong — unbounded channel accumulates thousands of jobs during traffic spikes; OOM crash.
✅ Correct — use a bounded channel with appropriate capacity and back-pressure mode.