Skip to main content
SEMastery

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.

13 min readUpdated March 19, 2026

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.

One notification fans out to many independent handlers.

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

Email
Shipping
Analytics
Loyalty

Steps

1

Email

0-200ms

2

Shipping

200-400ms

3

Analytics

400-600ms

4

Loyalty

600-800ms

Each handler waits for the previous one to finish.

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

Email
Shipping
Analytics
Loyalty

Steps

1

Email

0-200ms

2

Shipping

0-200ms

3

Analytics

0-200ms

4

Loyalty

0-200ms

All handlers start at the same moment and overlap.
Sequential waits in a line, parallel overlaps.

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:

SettingWhen to useLifetime
NotificationPublisherThe publisher has no dependencies you need from DIA single shared instance
NotificationPublisherTypeYour custom publisher needs injected servicesResolved 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.

StrategyWaits for handlers?Captures errors?Best for
ForeachAwaitPublisher (default)Yes, one by oneStops on first errorOrder matters, simple apps
TaskWhenAllPublisherYes, all togetherCollects errors in an AggregateExceptionIndependent, slow-ish handlers
ParallelNoWaitPublisher (custom)No, returns instantlyNo, errors are lostFire-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.

The three strategies and what they wait for.

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.

QuestionSequential defaultTaskWhenAll parallel
Do later handlers run after an error?NoYes, they all started already
How many errors do you see when you await?OneOne (first), rest hidden in the aggregate
Is handler order predictable?YesNo

Parallel error flow

Start all
Collect tasks
WhenAll settles
Aggregate errors
await rethrows first

Steps

1

Start all

every handler begins

2

WhenAll

wait for all to finish

3

Aggregate

all errors collected

4

await

first error thrown

All handlers run, errors are gathered, await surfaces the first.

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.

Choosing a publishing strategy.

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

Order placed
Publish event
Handlers start
WhenAll waits
Response returns

Steps

1

Order placed

controller calls Publish

2

Handlers start

email, ship, stats all begin

3

WhenAll

wait for slowest

4

Response

return once all done

From a placed order to all handlers finishing together.

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

Quick recap

  • A notification can have many handlers; the INotificationPublisher decides how they are called.
  • The default ForeachAwaitPublisher runs handlers one by one, so total time is the sum of all handlers.
  • TaskWhenAllPublisher starts every handler at once and waits with Task.WhenAll, so total time is closer to the slowest handler.
  • Turn it on with cfg.NotificationPublisher = new TaskWhenAllPublisher() when registering MediatR.
  • ParallelNoWaitPublisher returns 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 DbContext across parallel handlers — give each one its own scope.
  • When you await Task.WhenAll, only the first error is rethrown; the rest sit inside an AggregateException.
  • MediatR is now commercially licensed (since July 2025); check the licence before using it in a paid product.

Related Patterns