In-process events
An in-process event is a message that never leaves the running application: one part of the code announces that something happened and another part reacts, with no broker between them. The simplest form is a C# eventEventsHow a class tells other classes that something happened, without knowing who is listening.Opens in a new tab, built on delegatesDelegatesA type-safe function pointer, so a method can be passed around like any other value.Opens in a new tab. Its handlers run one after another on the caller’s thread, so the caller waits for all of them. A slow handler slows the request (see heavy work inside an HTTP requestHeavy work inside an HTTP requestCores are shared by every request, so one request that takes them all slows the others.Opens in a new tab).
A queue in the middle
To let the request return first, put a queue between the two sides. System.Threading.Channels is the .NET type for this: a producer writes items, a consumer reads them, and the channel handles the synchronization. In the shop, the Orders code writes OrderPlaced into the channel and answers 202 Accepted. A hostedThe host and WebApplicationBuilderWhat starts an ASP.NET Core application and keeps it running.Opens in a new tab BackgroundService reads the channel and sends the confirmation. It is the same producer and consumer shape as in streaming through a channelLoad everything then render, or stream through a channelTwo shapes for a job that loads data (I/O) and renders it (CPU).Opens in a new tab.
In code
The channel is wrapped in a small class so it can be registered as a singleton (see dependency injectionDependency injection and service lifetimesHow ASP.NET Core hands each class what it needs, and how long each instance lives.Opens in a new tab). The worker is a loop over the channel, and one failed item is logged without ending the loop.
public sealed record OrderPlaced(Guid OrderId, decimal Total); public sealed class OrderPlacedQueue{ private readonly Channel<OrderPlaced> _channel = Channel.CreateBounded<OrderPlaced>( new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.Wait }); public ValueTask EnqueueAsync(OrderPlaced evt, CancellationToken ct) => _channel.Writer.WriteAsync(evt, ct); public IAsyncEnumerable<OrderPlaced> ReadAllAsync(CancellationToken ct) => _channel.Reader.ReadAllAsync(ct);} public sealed class ConfirmationWorker( OrderPlacedQueue queue, IEmailSender email, ILogger<ConfirmationWorker> log) : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await foreach (var evt in queue.ReadAllAsync(stoppingToken)) { try { await email.SendConfirmationAsync(evt.OrderId, stoppingToken); } catch (Exception ex) when (ex is not OperationCanceledException) { log.LogError(ex, "Confirmation failed for {OrderId}", evt.OrderId); } } }}The endpoint writes and returns. AddHostedService starts the worker with the application.
builder.Services.AddSingleton<OrderPlacedQueue>();builder.Services.AddHostedService<ConfirmationWorker>(); app.MapPost("/orders", async (PlaceOrder cmd, OrderPlacedQueue queue, CancellationToken ct) =>{ var orderId = Guid.NewGuid(); // ... save the order ... await queue.EnqueueAsync(new OrderPlaced(orderId, cmd.Total), ct); return Results.Accepted($"/orders/{orderId}");});Back-pressure
A bounded channel has a capacity. When the consumer is slower than the producers, the channel fills, and FullMode decides what happens. The default, Wait, makes WriteAsync wait for space and makes TryWrite return false. The other modes drop the oldest item, the newest item, or the item being written. With Wait, the slowness reaches the producer, which is called back-pressure. An unbounded channel never pushes back, so a slow consumer lets it grow until memory runs out.
The catch
The queue lives in memory only. If the process crashes or is restarted, whatever was in the channel is gone. Microsoft’s hosted services documentation notes that StopAsync might not be called when the process fails. On a normal shutdown the stopping token is cancelled, and items still in the channel are not read unless the worker drains them. There is no acknowledgement, no retry and no dead-letter queue. A second instance of the application has its own channel, so the work is not shared between instances.
In the example, a lost OrderPlaced means a customer who gets no confirmation email.
Where it fits
Use it for work that can be repeated or can be lost without harm, such as refreshing a cache or building a thumbnail. When the work must survive a crash, write it to a database or a broker instead: see the outboxOutbox and inboxWrite the event in the same transaction as the data, publish it from there, and deduplicate on the way in.Opens in a new tab and delivery guaranteesDelivery guaranteesAt-most-once, at-least-once and exactly-once: what a broker promises, and what it cannot.Opens in a new tab.
Sources
- Channels, Microsoft Learn · accessed June 12, 2026
- Background tasks with hosted services in ASP.NET Core, Microsoft Learn · accessed June 12, 2026