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.
The tea stall with many helpers
Picture a small tea stall near a railway station. A train arrives and twenty people walk up at once. Each person wants something a little different: one wants chai, one wants a samosa warmed, one wants change for a note, one wants a bottle of water.
Now imagine the stall owner has only one pair of hands. He serves the first person completely, then the second, then the third. The twentieth person waits a very long time. This is slow, but it is simple. He never gets confused.
Then imagine the owner hires four helpers. When the crowd arrives, all four start serving different people at the same moment. The chai helper boils water while the samosa helper warms the samosa. Nobody waits for the others. The whole crowd is served in roughly the time of the slowest single order, not the sum of all of them.
This is exactly the choice we make in MediatR when we publish a notification. We can serve the handlers one by one, or we can let them all start together. Running them together is called parallel publishing. In this post we will learn how to do it safely, when it helps, and when it can quietly break things.
A quick recap of notifications
In MediatR there are two kinds of messages.
- A request (or command) has exactly one handler. Like "get order 42" or "create a user". It usually returns a value.
- A notification is a broadcast. It says "something happened" and can have zero, one, or many handlers. Like "an order was placed" — email, shipping, loyalty points, and analytics may all care.
When you call publisher.Publish(notification), MediatR finds every handler for that notification and calls them. The small part that decides how they are called — one by one, or all at once — is called the INotificationPublisher.
The default: one handler at a time
By default, MediatR uses a publisher called ForeachAwaitPublisher. It does the simplest possible thing: it loops over the handlers and awaits each one before moving to the next.
// This is roughly what the built-in ForeachAwaitPublisher does.
public async Task Publish(
IEnumerable<NotificationHandlerExecutor> handlers,
INotification notification,
CancellationToken cancellationToken)
{
foreach (var handler in handlers)
{
// Wait for THIS handler to finish before starting the next one.
await handler.HandlerCallback(notification, cancellationToken);
}
}This is like the tea stall owner with one pair of hands. It is safe and predictable. Handlers run in a known order, and if one throws an error, the loop stops right there.
But the cost is time. If you have four handlers that each take 200 milliseconds because they call a database or an API, the total is 200 + 200 + 200 + 200 = 800 milliseconds. Your user waits for the whole chain.
Sequential publishing timeline
Steps
0-200ms
Shipping
200-400ms
Analytics
400-600ms
Loyalty
600-800ms
The parallel idea: start them all together
Most of the time, those four handlers do not depend on each other. The email handler does not care what the analytics handler is doing. So why make them wait in a line?
Parallel publishing starts every handler at once and then waits for all of them to finish together. The trick in .NET is Task.WhenAll. MediatR ships a built-in publisher for exactly this, called TaskWhenAllPublisher.
// This is roughly what the built-in TaskWhenAllPublisher does.
public Task Publish(
IEnumerable<NotificationHandlerExecutor> handlers,
INotification notification,
CancellationToken cancellationToken)
{
// Start every handler, collecting their tasks WITHOUT awaiting yet.
var tasks = handlers
.Select(handler => handler.HandlerCallback(notification, cancellationToken))
.ToArray();
// Now wait for all of them to complete together.
return Task.WhenAll(tasks);
}Notice the important detail: we call each handler first and collect the returned Task objects into an array before we await anything. That is what lets them overlap. If those same four handlers each take 200 milliseconds of waiting, the total is now close to 200 milliseconds, not 800. They all wait at the same time.
Parallel publishing timeline
Steps
0-200ms
Shipping
0-200ms
Analytics
0-200ms
Loyalty
0-200ms
Turning on parallel publishing
The good news is that you usually do not need to write any of that code yourself. When you register MediatR, you can tell it which publisher to use. Here is how to switch to the built-in parallel one.
using MediatR;
using MediatR.NotificationPublishers;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
// Run all notification handlers in parallel and wait for all of them.
cfg.NotificationPublisher = new TaskWhenAllPublisher();
// If your publisher needs DI services, register it by type instead:
// cfg.NotificationPublisherType = typeof(TaskWhenAllPublisher);
});
var app = builder.Build();That one line, cfg.NotificationPublisher = new TaskWhenAllPublisher(), changes the behaviour for every notification in your app. From now on, when you publish any notification, all of its handlers start together.
There are two ways to register a publisher, and the difference matters:
| Setting | When to use | Lifetime |
|---|---|---|
NotificationPublisher | The publisher has no dependencies you need from DI | A single shared instance |
NotificationPublisherType | Your custom publisher needs injected services | Resolved from the container |
The three common strategies
People usually talk about three publishing strategies. The first is built into MediatR by default, the second is the built-in parallel one, and the third is a custom one you often see in samples. Here is how they compare.
| Strategy | Waits for handlers? | Captures errors? | Best for |
|---|---|---|---|
ForeachAwaitPublisher (default) | Yes, one by one | Stops on first error | Order matters, simple apps |
TaskWhenAllPublisher | Yes, all together | Collects errors in an AggregateException | Independent, slow-ish handlers |
ParallelNoWaitPublisher (custom) | No, returns instantly | No, errors are lost | Fire-and-forget, low importance |
The third one, ParallelNoWaitPublisher, is worth understanding because it is a trap dressed up as a feature. It uses Task.Run for each handler and returns immediately without waiting.
// A custom "fire and forget" publisher. Looks fast. Hides danger.
public class ParallelNoWaitPublisher : INotificationPublisher
{
public Task Publish(
IEnumerable<NotificationHandlerExecutor> handlers,
INotification notification,
CancellationToken cancellationToken)
{
foreach (var handler in handlers)
{
// Start on a background thread and DO NOT keep the task.
Task.Run(() => handler.HandlerCallback(notification, cancellationToken));
}
// Return right away. We never find out if a handler failed.
return Task.CompletedTask;
}
}This looks wonderfully fast because Publish returns the instant it is called. But there is a price. If a handler throws an exception, nobody catches it. The error simply vanishes. Even if you await the call to Publish, you are awaiting a task that was already complete, so you learn nothing. Use this only for work you truly do not care about losing.
Handling errors in parallel
This is the part many people get wrong, so let us go slowly.
With the sequential default, the moment one handler throws, the loop stops. The handlers after it never run, and you see one clean exception. Easy to reason about, but you might wanted the other handlers to still run.
With TaskWhenAllPublisher, all handlers are already running before anything fails. If two of them throw, Task.WhenAll gathers both errors into an AggregateException. But there is a sharp edge: when you await Task.WhenAll(...), C# unwraps the exception and throws only the first one. The other errors are still there, but you have to look for them.
try
{
await _publisher.Publish(new OrderPlaced(orderId), cancellationToken);
}
catch (Exception ex)
{
// await re-threw only the FIRST exception. To see them all,
// inspect the Task or wrap the call and read AggregateException.
_logger.LogError(ex, "At least one OrderPlaced handler failed");
}If you must see every error, do not await directly. Instead capture the task, wait for it to settle, and read its Exception property, which holds the full AggregateException. The table below sums up the behaviour.
| Question | Sequential default | TaskWhenAll parallel |
|---|---|---|
| Do later handlers run after an error? | No | Yes, they all started already |
How many errors do you see when you await? | One | One (first), rest hidden in the aggregate |
| Is handler order predictable? | Yes | No |
Parallel error flow
Steps
Start all
every handler begins
WhenAll
wait for all to finish
Aggregate
all errors collected
await
first error thrown
The danger nobody warns you about: shared services
Parallel speed is lovely, but it comes with a rule you must respect. When handlers run at the same time, they may touch the same shared object at the same time. Some objects hate that.
The classic example is Entity Framework Core's DbContext. It is not thread-safe. It is designed to be used by one operation at a time. If two parallel handlers both use the same scoped DbContext instance, you will get errors like "A second operation was started on this context before a previous operation completed."
Think of the DbContext as a single shared notebook. One helper at the tea stall can write in it fine. But if four helpers all grab the same notebook and scribble at once, the pages tear.
So before you switch to parallel publishing, ask:
- Do my handlers share a scoped service like
DbContext? - Do they write to the same in-memory list or dictionary without locks?
- Does the order of handlers secretly matter (handler B reads what handler A wrote)?
If the answer to any of these is yes, parallel publishing can corrupt data or crash. The safe fixes are: give each handler its own scope so it gets its own DbContext, or keep those handlers on the sequential publisher.
public class AnalyticsHandler : INotificationHandler<OrderPlaced>
{
private readonly IServiceScopeFactory _scopeFactory;
public AnalyticsHandler(IServiceScopeFactory scopeFactory)
=> _scopeFactory = scopeFactory;
public async Task Handle(OrderPlaced note, CancellationToken ct)
{
// Create a fresh scope so THIS handler gets its own DbContext.
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Stats.Add(new OrderStat(note.OrderId));
await db.SaveChangesAsync(ct);
}
}When parallel actually helps (and when it does not)
Here is the honest truth. Parallel publishing only helps when your handlers spend most of their time waiting. Waiting for a database. Waiting for an HTTP call. Waiting for an email server. That waiting time can overlap, and overlapping is where the win comes from.
If your handlers do heavy CPU work — crunching numbers, resizing images, sorting huge lists — running them together does not give you free time. They all share the same handful of CPU cores. You may even make things slower because of the cost of switching between tasks.
So the rule is simple. I/O-bound handlers love parallel. CPU-bound handlers do not get much from it.
A note on the MediatR licence
One practical thing to keep in mind. MediatR became a commercial product in July 2025 under Lucky Penny Software. There is a free community tier and paid tiers based on team size. The older MIT-licensed versions are still available, but if you are adding MediatR to a paid product, check the current licence first. The publishing strategies described here exist in MediatR version 12 and later.
If you ever want to avoid the dependency entirely, the same parallel idea works in a hand-written dispatcher — you collect the handler tasks into an array and call Task.WhenAll. The pattern is the star here, not the library.
Putting it together
Let us walk through what actually happens when an order is placed in an app using TaskWhenAllPublisher.
End to end parallel publish
Steps
Order placed
controller calls Publish
Handlers start
email, ship, stats all begin
WhenAll
wait for slowest
Response
return once all done
The controller publishes one notification. MediatR hands all the matching handlers to the TaskWhenAllPublisher. It starts each one, collects the tasks, and waits for the whole group with Task.WhenAll. The user waits roughly as long as the slowest single handler, not the sum of all of them. And because we waited, any errors are still reported instead of silently lost.
That balance — fast, but still honest about failures — is why TaskWhenAllPublisher is the strategy most teams should reach for first.
References and further reading
- MediatR Wiki — LuckyPennySoftware (GitHub) — official notification publisher docs.
- How To Publish MediatR Notifications In Parallel — Milan Jovanović — clear walkthrough of all three strategies.
- Publish MediatR Notifications in Parallel — Code Maze — examples with error handling.
- MediatR PublishStrategies sample (GitHub) — the official sample showing custom publishers.
Quick recap
- A notification can have many handlers; the
INotificationPublisherdecides how they are called. - The default
ForeachAwaitPublisherruns handlers one by one, so total time is the sum of all handlers. TaskWhenAllPublisherstarts every handler at once and waits withTask.WhenAll, so total time is closer to the slowest handler.- Turn it on with
cfg.NotificationPublisher = new TaskWhenAllPublisher()when registering MediatR. ParallelNoWaitPublisherreturns instantly but loses errors — use it only for unimportant fire-and-forget work.- Parallel publishing helps I/O-bound handlers, not heavy CPU work.
- Never share a non-thread-safe object like
DbContextacross parallel handlers — give each one its own scope. - When you
await Task.WhenAll, only the first error is rethrown; the rest sit inside anAggregateException. - MediatR is now commercially licensed (since July 2025); check the licence before using it in a paid product.
Related Patterns
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.
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.
Building a Custom Domain Events Dispatcher in .NET (No MediatR Needed)
Build your own domain events dispatcher in .NET with EF Core. Simple analogy, full C# code, diagrams, and timing tips — no paid MediatR license required.
CQRS Validation with MediatR Pipeline and FluentValidation in .NET
Learn centralized CQRS validation in .NET using a MediatR pipeline behavior and FluentValidation. Simple words, clear diagrams, and real C# code.
Stop Conflating CQRS and MediatR: They Are Not the Same Thing
CQRS and MediatR are two different ideas. Learn what each one really does, why people mix them up, and how to use CQRS in .NET with or without MediatR.
MediatR and MassTransit Going Commercial: What This Means for You
MediatR and MassTransit are now commercially licensed. Learn what changed, who pays, who stays free, and how to plan your .NET project calmly.