Skip to main content
SEMastery

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.

11 min readUpdated April 26, 2026

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:

PublisherWhat it doesTrade-off
ForeachAwaitPublisherRuns handlers one by one, awaiting eachSafe order, but slow if many handlers
TaskWhenAllPublisherRuns all handlers at the same timeFaster, 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.

The default MediatR flow: the caller waits for every handler to finish.

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 typeCapacityWhen the writer is full
UnboundedUnlimited (grows freely)Never blocks, but can eat all your memory
BoundedFixed limit you chooseWriter 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

Writer
Channel
Reader

Steps

1

Writer

WriteAsync drops an item

2

Channel

Thread-safe buffer holds items

3

Reader

ReadAllAsync pulls items out

The writer never touches the reader directly. The Channel sits safely between them.

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.

The Channel-backed publisher: the request returns fast while a background worker does the slow work.

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

Event happens
Queue it
Worker runs it
Loss is harmless

Steps

1

Event happens

e.g. cache warm

2

Queue it

drop into Channel

3

Worker runs it

background thread

4

Loss is harmless

safe to skip on crash

Use the in-memory queue only when a lost message causes no real harm.

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.

The failure moment: a crash empties the in-memory queue and the work is lost forever.

Compare the two approaches side by side:

ConcernChannel publisherOutbox + real broker
Survives a crashNoYes
Automatic retriesNoYes
Works across many instancesNoYes
Dead-letter for poison messagesNoYes
Setup effortTinyLarger
Safe for money / ordersNoYes

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

Save data + outbox row
Commit together
Worker reads outbox
Publish to broker
Mark done

Steps

1

Save data + outbox row

one transaction

2

Commit together

both or neither

3

Worker reads outbox

polls the table

4

Publish to broker

at-least-once

5

Mark done

or retry later

Save the message with your data, then a worker delivers it reliably.

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 INotificationPublisher decides how they are called.
  • The two built-in publishers (ForeachAwait and TaskWhenAll) both make the caller wait for every handler.
  • A Channel is a fast, thread-safe, in-memory queue. ReadAllAsync() gives a clean await foreach consumer.
  • You can build a custom publisher that drops notifications into a Channel and returns instantly, with a BackgroundService doing 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

Related Patterns