Building a Better MediatR Publisher With Channels (and Why You Shouldn't)
Build a custom MediatR INotificationPublisher using System.Threading.Channels for background events in .NET, then learn why a queue this simple can quietly lose your data.
The samosa shop and the order slip
Imagine a busy samosa shop in the market. You walk up, pay, and the man at the counter hands you a small paper slip with a number on it. He does not make you stand there while he fries your samosas. He drops your slip into a tray, says "next!", and serves the person behind you. In the back, a cook picks up slips one by one and prepares each order.
This is a lovely trick. The counter man is fast because he never does slow work himself. He just takes your slip and moves on. The slow frying happens later, in the back, by someone else.
In .NET we can build exactly this. The counter man is your web request. The slip is a notification (an event saying "something happened"). The tray is a Channel. The cook in the back is a background worker. MediatR lets us plug in our own "tray system" by writing a custom INotificationPublisher.
In this post we will build that fast counter. And then — like a careful teacher — I will show you the hidden danger. Because that paper tray? If a strong wind blows through the shop, every slip in it flies away, and those orders are gone forever. Let us learn both the magic and the warning.
What a notification publisher actually does
In MediatR, there are two kinds of messages:
- A request (or command) has exactly one handler and usually returns a result. Like "get me order 42".
- A notification is a broadcast. It can have zero, one, or many handlers. Like "an order was placed" — email, shipping, and analytics may all want to know.
When you call publisher.Publish(notification), MediatR finds all the handlers for that notification. But who decides the order and timing of calling them? That job belongs to a tiny part called the INotificationPublisher.
MediatR ships with two built-in ones:
| Publisher | What it does | Trade-off |
|---|---|---|
ForeachAwaitPublisher | Runs handlers one by one, awaiting each | Safe order, but slow if many handlers |
TaskWhenAllPublisher | Runs all handlers at the same time | Faster, but order is not guaranteed |
Both of these still make the caller wait until every handler finishes. If one handler sends a slow email, your user stares at a spinner. Our goal is to stop making the user wait.
Step 1: meet System.Threading.Channels
A Channel<T> is a thread-safe, in-memory queue built right into .NET. One side writes items, the other side reads them, and the runtime handles all the locking and waiting for you. It is the modern, async-friendly replacement for older patterns like BlockingCollection.
There are two flavours:
| Channel type | Capacity | When the writer is full |
|---|---|---|
| Unbounded | Unlimited (grows freely) | Never blocks, but can eat all your memory |
| Bounded | Fixed limit you choose | Writer waits, drops, or errors based on your setting |
Here is the simplest possible producer and consumer:
using System.Threading.Channels;
// Create a queue that holds at most 100 items.
var channel = Channel.CreateBounded<string>(100);
// Producer: drop a slip into the tray.
await channel.Writer.WriteAsync("Order #42");
// Consumer: the cook reads slips until the tray is closed.
await foreach (string slip in channel.Reader.ReadAllAsync())
{
Console.WriteLine($"Cooking {slip}");
}The magic line is ReadAllAsync(). It gives you an IAsyncEnumerable<T>. The await foreach loop simply waits politely when the tray is empty and wakes up the moment a new slip arrives. No busy-looping, no wasted CPU.
Producer and consumer through a Channel
Steps
Writer
WriteAsync drops an item
Channel
Thread-safe buffer holds items
Reader
ReadAllAsync pulls items out
Step 2: a custom INotificationPublisher backed by a Channel
Now we combine the two ideas. We will write an INotificationPublisher whose only job is to drop each notification into a Channel and return immediately. The actual handler work happens later, in a background service.
First, the publisher. It does not call any handlers at all — it just queues.
using System.Threading.Channels;
using MediatR;
public sealed class ChannelNotificationPublisher : INotificationPublisher
{
private readonly Channel<QueuedNotification> _channel;
public ChannelNotificationPublisher(Channel<QueuedNotification> channel)
{
_channel = channel;
}
public async Task Publish(
IEnumerable<NotificationHandlerExecutor> handlers,
INotification notification,
CancellationToken cancellationToken)
{
// Do NOT run the handlers now. Just queue the slip and leave.
await _channel.Writer.WriteAsync(
new QueuedNotification(handlers, notification),
cancellationToken);
}
}
public sealed record QueuedNotification(
IEnumerable<NotificationHandlerExecutor> Handlers,
INotification Notification);Notice what we did not do: we never awaited a single handler. The caller's thread is free in microseconds. That is the speed boost.
Next, the background cook. This is an IHostedService (via BackgroundService) that reads the Channel forever and runs the queued handlers.
using System.Threading.Channels;
using MediatR;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class NotificationProcessor : BackgroundService
{
private readonly Channel<QueuedNotification> _channel;
private readonly ILogger<NotificationProcessor> _logger;
public NotificationProcessor(
Channel<QueuedNotification> channel,
ILogger<NotificationProcessor> logger)
{
_channel = channel;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var item in _channel.Reader.ReadAllAsync(stoppingToken))
{
foreach (var handler in item.Handlers)
{
try
{
// This runs each handler. If one throws, we log and keep going.
await handler.HandlerCallback(item.Notification, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Handler failed for {Notification}",
item.Notification.GetType().Name);
}
}
}
}
}Finally, wire it up in Program.cs:
var channel = Channel.CreateBounded<QueuedNotification>(
new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait
});
builder.Services.AddSingleton(channel);
builder.Services.AddHostedService<NotificationProcessor>();
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.NotificationPublisher = new ChannelNotificationPublisher(channel);
cfg.NotificationPublisherType = typeof(ChannelNotificationPublisher);
});That is it. Your Publish calls now feel instant. Let us look at the full picture.
Why this feels so good (and is sometimes fine)
For some jobs, this pattern is genuinely useful and safe enough:
- Writing a log line or a metric.
- Warming a cache.
- Sending a "nice to have" notification that nobody will miss if it slips once in a blue moon.
For these, losing one message during a rare crash is not the end of the world. The speed and simplicity are worth it. The whole thing is in-memory, needs no database, and no broker. For a side project, a demo, or truly throwaway events, it is a fair choice.
When the fast path is acceptable
Steps
Event happens
e.g. cache warm
Queue it
drop into Channel
Worker runs it
background thread
Loss is harmless
safe to skip on crash
Now the warning: why you shouldn't (for important work)
Here is the part most blog posts skip. That paper tray full of slips has no roof. Let me list the real dangers, plainly.
1. The Channel lives only in memory. If your app pod restarts, deploys, or crashes, every notification still sitting in the Channel vanishes. There is no disk, no recovery, no log of what was lost. The user already got "200 OK", so they think their email is coming. It never will.
2. There is no retry. In our worker, if a handler throws, we log it and move on. The message is gone. A real system needs to try again later, maybe with a delay, maybe a few times.
3. There is no ordering guarantee across instances. Run two copies of your app behind a load balancer and each has its own private Channel. A notification queued on instance A is invisible to instance B.
4. Back-pressure becomes a silent trap. With BoundedChannelFullMode.Wait, if the worker is slow, your fast request threads start waiting to write — and suddenly your "instant" API is slow again. With an unbounded Channel, a flood of events can eat all your memory and crash the process.
5. You silently rebuilt a worse message broker. Real brokers (RabbitMQ, Azure Service Bus, Kafka) already solve persistence, retries, dead-letter queues, and delivery across machines. Your hand-rolled Channel solves none of these.
Compare the two approaches side by side:
| Concern | Channel publisher | Outbox + real broker |
|---|---|---|
| Survives a crash | No | Yes |
| Automatic retries | No | Yes |
| Works across many instances | No | Yes |
| Dead-letter for poison messages | No | Yes |
| Setup effort | Tiny | Larger |
| Safe for money / orders | No | Yes |
What to do instead for important events
If the event matters — an order, a payment, a "your account was created" email — you need the message to survive a crash. The trusted answer is the Outbox Pattern.
The idea: when you save your data, you also save the message into an outbox table in the same database transaction. Because they commit together, the message can never be lost. A separate background worker then reads the outbox table and publishes to a real broker, marking rows as done.
This is the grown-up version of our samosa slip — except now the slips are written in a sturdy notebook that survives the wind.
The durable alternative
Steps
Save data + outbox row
one transaction
Commit together
both or neither
Worker reads outbox
polls the table
Publish to broker
at-least-once
Mark done
or retry later
A simple sketch of the durable write:
public async Task PlaceOrder(Order order)
{
_db.Orders.Add(order);
// The message rides along in the SAME transaction as the data.
_db.OutboxMessages.Add(new OutboxMessage
{
Type = nameof(OrderPlaced),
Payload = JsonSerializer.Serialize(new OrderPlaced(order.Id)),
OccurredOn = DateTime.UtcNow
});
// Both succeed together, or neither does. No message can be lost.
await _db.SaveChangesAsync();
}A separate worker then reads unprocessed outbox rows, publishes them, and marks them done — with retries when something fails. That is the safety our Channel could never offer.
A quick word on the MediatR licence
One more thing worth knowing in 2026. In July 2025, MediatR (and AutoMapper) moved to a commercial model under Jimmy Bogard's company, Lucky Penny Software. There is a free community tier and paid tiers based on team size, and the older MIT-licensed versions remain available.
So before you build anything serious on MediatR, check which licence applies to your team. Many developers now also write a tiny in-house mediator, since the core idea — find handlers, call them — is only a handful of lines. The INotificationPublisher concept we explored here works the same way no matter which mediator you use.
Quick recap
- A notification in MediatR can have many handlers; the
INotificationPublisherdecides how they are called. - The two built-in publishers (
ForeachAwaitandTaskWhenAll) both make the caller wait for every handler. - A Channel is a fast, thread-safe, in-memory queue.
ReadAllAsync()gives a cleanawait foreachconsumer. - You can build a custom publisher that drops notifications into a Channel and returns instantly, with a
BackgroundServicedoing the slow work later. This makes responses feel quick. - The big danger: an in-memory Channel dies with the process. A crash loses every queued message — no persistence, no retry, no cross-instance delivery.
- Use the Channel trick only for harmless events (logs, cache warming, nice-to-haves).
- For important events (orders, payments, emails), use the Outbox Pattern with a real broker so messages survive crashes and get retried.
- Remember MediatR is commercially licensed since July 2025 — check your tier before shipping.
References and further reading
- System.Threading.Channels — Microsoft Learn
- Building a Better MediatR Publisher With Channels (and why you shouldn't) — Milan Jovanović
- Lightweight In-Memory Message Bus Using .NET Channels — Milan Jovanović
- AutoMapper and MediatR Commercial Editions Launch Today — Jimmy Bogard
- Implementing the Outbox Pattern — Milan Jovanović
Related Patterns
The Outbox Pattern in .NET: Never Lose a Message Again
Learn the Outbox Pattern in .NET with simple, real-life examples. Save your data and your messages in one transaction so a broker outage can never lose an event. Includes EF Core code, diagrams, and best practices.
The Inbox Pattern in .NET: Handle Each Message Exactly Once
Learn the Inbox Pattern in .NET to stop duplicate messages from causing double charges and double emails. Simple real-life examples, EF Core code, diagrams, and how it pairs with the Outbox Pattern.
Event Sourcing for .NET Developers: A Simple Introduction
Learn event sourcing in .NET from scratch. Store every change as an event instead of just the current state, with a real-life bank-passbook analogy, diagrams, code, aggregates, projections, and when to use it.
Lightweight In-Memory Message Bus Using .NET Channels
Build a fast, thread-safe in-memory message bus in .NET using System.Threading.Channels, with a friendly pub/sub design, full code, and the safety warnings you need.
How to Publish MediatR Notifications in Parallel in .NET
Learn how to publish MediatR notifications in parallel using a custom INotificationPublisher in .NET, with Task.WhenAll, error handling, and clear examples.
CQRS Pattern with MediatR in .NET: A Friendly Guide
Learn the CQRS pattern with MediatR in .NET using simple words, clear diagrams, and real C# code. Beginner friendly, with pitfalls and licensing notes.