Performance Overview
The non-MediatR comparisons below predate the 2026-09-05 correlation fix and are being re-measured. Restoring correlation on the default fast path cost about 10-11 ns on the Dispatch side of every in-process comparison, so any ratio on this page that was not re-measured overstates Dispatch's lead. The MediatR rows have been re-measured; the Wolverine and MassTransit rows have not. Treat them as an upper bound on our favour until they are.
Excalibur.Dispatch is designed for low-latency messaging with explicit performance profiles for local and transport paths.
Before You Start
- .NET 10.0 (benchmarks validated on .NET 10.0.11, SDK 10.0.400, BenchmarkDotNet 0.15.8)
- Familiarity with pipeline profiles and middleware
Key Performance Metrics
Source: the WarmPath comparison epoch of 2026-09-05, committed at
benchmarks/baselines/net10.0/dispatch-comparative-20260905/results/.
| Metric | Value | Source |
|---|---|---|
| Single command, caller-supplied context | 45.6 ns / 96 B | MediatRWarmPathComparisonBenchmarks |
| Single command, context-less 2-arg overload | 53.0 ns / 96 B | MediatRWarmPathComparisonBenchmarks |
| Singleton-promoted handler | 53.9 ns / 96 B | MediatRWarmPathComparisonBenchmarks |
| Query with return value | 63.7 ns / 192 B | MediatRWarmPathComparisonBenchmarks |
| Notification to 3 handlers | 135.0 ns / 96 B | MediatRWarmPathComparisonBenchmarks |
| Three-middleware pipeline | 71.7 ns / 240 B | PipelineWarmPathComparisonBenchmarks |
| 100 concurrent commands | 5,584 ns / 12,160 B | MediatRWarmPathComparisonBenchmarks |
Dispatch vs Wolverine InvokeAsync | 47.0 ns / 96 B vs 179.1 ns / 584 B (3.8x faster, 6.1x less memory) | WolverineInProcessWarmPathComparisonBenchmarks |
Every dispatch publishes an ambient message context, so a nested dispatch inherits causation,
correlation, tenant and user rather than starting a fresh root. That costs one ExecutionContext
copy-on-write, and the copy is of the whole async-local value map — so what you pay scales with how
many AsyncLocal values your application keeps live: 72 of the 96 bytes when there are none,
roughly 160 B with one other, roughly 992 B with fifteen. A production host usually carries several
before it reaches this framework, and the framework itself declares two. Budget from your own
async-local density, not from the floor.
Allocation is byte-identical between runs. Latency is not: the same arm varied about 4% run to run for Dispatch and about 8.6% for MediatR, which is several times BenchmarkDotNet's reported error — that error measures spread within one process, not reproducibility between processes. Do not read a latency ratio inside that band as a finding, in either direction.
These numbers are from the September 5, 2026 epoch. Baselines from before April 2026 used
BenchmarkDotNet 0.15.4, which auto-tuned InvocationCount and produced numerically different (but
qualitatively equivalent) ns-scale numbers. Do not cross-diff individual Mean values across that
boundary — ratios within each report remain apples-to-apples. See benchmarks/RUNBOOK.md for the
methodology shift.
Comparison Snapshot
| Track | Summary |
|---|---|
| MediatR in-process parity | MediatR is ~1.40x faster on a single command; Dispatch allocates 1.58x less on that row. Fan-out and pipeline rows have not been re-measured since the correlation fix |
| Wolverine in-process parity | Dispatch ~3.8x faster on command, ~4.0x on query, ~1.66x on notification fan-out, allocating 2.7-6.3x less |
| MassTransit in-memory parity | Dispatch leads every in-process tier by two orders of magnitude against the bus and by ~17-22x against its mediator |
| Pipeline parity (3 middleware) | Dispatch leads every framework measured — 1.74x over MediatR, 3.30x over Wolverine, 29.7x over MassTransit, at 2.83x, 2.83x and 19.0x less allocation |
See Competitor Comparison for full tables and methodology notes. On the 100-concurrent-command tier Dispatch allocates 12,160 B against MediatR's 17,064 B, with the two within run-to-run variance on latency.
Quick Wins
1. Let the local fast path select itself
var result = await dispatcher.DispatchAsync(new CreateOrderAction(...), ct);
This is automatic. The local fast path is selected for you when the message can stay in-process and no middleware applies to its type; there is no explicit API to call. See Migrating off IDirectLocalDispatcher if you were using one.
2. Keep messages deterministic where possible
public record CreateOrderCommand(Guid OrderId, string CustomerId) : IDispatchAction;
public class CreateOrderHandler : IActionHandler<CreateOrderCommand> { }
3. Keep auto-freeze enabled
var host = builder.Build();
await host.RunAsync();
4. Prefer direct IMessageContext properties
context.ProcessingAttempts++;
Performance Guides
| Guide | Description |
|---|---|
| Migrating off IDirectLocalDispatcher | The removed explicit local API, and what to call instead |
| Auto-Freeze | Automatic cache optimization |
| MessageContext Best Practices | Hot-path optimization patterns |
| Competitor Comparison | Multi-track benchmarks vs MediatR/Wolverine/MassTransit |
Hot-Path Optimizations
Nine micro-optimizations targeting the dispatch hot path:
| Optimization | Pattern |
|---|---|
Dual-write elimination in RoutingDecisionAccessor | Single-write via CachedRoutingDecision field with Features dictionary fallback |
RoutingDecision.Local singleton | Cached static property (like Task.CompletedTask) |
Lock removal on MessageContext.Success | Volatile fields + AggressiveInlining |
Single-lookup GetOrCreateFeature | TryGetValue + direct store |
| Lightweight context init | Skip GetTransportBinding for outbound dispatches when no transport correlation needed |
| Per-profile middleware bypass | Pre-computed _hasAnyNonRoutingMiddleware flag skips FrozenDictionary chain lookup |
| Single transport bus pre-resolution | Pre-resolve single non-local bus at construction, bypass ConcurrentDictionary lookup |
| Routing decision cache | ConcurrentDictionary<Type, RoutingDecision> for deterministic single-route types |
| Combined transport fast path | The four transport optimizations above compose into a single pre-resolved outbound path |
Memory Allocation Strategy
Dispatch reduces allocations through:
- Object pooling for
MessageContext ArrayPool<T>on batch-style paths- Lazy initialization for optional context state
- ValueTask-based local fast paths
- Hot-path single-write patterns eliminating redundant dictionary allocations
- Package extraction reducing dependency graph complexity
Running Benchmarks
# Full matrix refresh
pwsh ./eng/run-benchmark-matrix.ps1 -NoRestore -NoBuild
# In-process parity track
pwsh ./eng/run-benchmark-matrix.ps1 -NoRestore -NoBuild -Classes MediatRComparisonBenchmarks,WolverineInProcessComparisonBenchmarks,MassTransitMediatorComparisonBenchmarks
# Queued/bus end-to-end parity track
pwsh ./eng/run-benchmark-matrix.ps1 -NoRestore -NoBuild -Classes TransportQueueParityComparisonBenchmarks
Results default to benchmarks/runs/BenchmarkDotNet.Artifacts/results/.