Skip to main content

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/.

MetricValueSource
Single command, caller-supplied context45.6 ns / 96 BMediatRWarmPathComparisonBenchmarks
Single command, context-less 2-arg overload53.0 ns / 96 BMediatRWarmPathComparisonBenchmarks
Singleton-promoted handler53.9 ns / 96 BMediatRWarmPathComparisonBenchmarks
Query with return value63.7 ns / 192 BMediatRWarmPathComparisonBenchmarks
Notification to 3 handlers135.0 ns / 96 BMediatRWarmPathComparisonBenchmarks
Three-middleware pipeline71.7 ns / 240 BPipelineWarmPathComparisonBenchmarks
100 concurrent commands5,584 ns / 12,160 BMediatRWarmPathComparisonBenchmarks
Dispatch vs Wolverine InvokeAsync47.0 ns / 96 B vs 179.1 ns / 584 B (3.8x faster, 6.1x less memory)WolverineInProcessWarmPathComparisonBenchmarks
The allocation figure is a floor

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.

Read latency as indicative, allocation as exact

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.

Epoch boundary

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​

TrackSummary
MediatR in-process parityMediatR 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 parityDispatch ~3.8x faster on command, ~4.0x on query, ~1.66x on notification fan-out, allocating 2.7-6.3x less
MassTransit in-memory parityDispatch 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​

GuideDescription
Migrating off IDirectLocalDispatcherThe removed explicit local API, and what to call instead
Auto-FreezeAutomatic cache optimization
MessageContext Best PracticesHot-path optimization patterns
Competitor ComparisonMulti-track benchmarks vs MediatR/Wolverine/MassTransit

Hot-Path Optimizations​

Nine micro-optimizations targeting the dispatch hot path:

OptimizationPattern
Dual-write elimination in RoutingDecisionAccessorSingle-write via CachedRoutingDecision field with Features dictionary fallback
RoutingDecision.Local singletonCached static property (like Task.CompletedTask)
Lock removal on MessageContext.SuccessVolatile fields + AggressiveInlining
Single-lookup GetOrCreateFeatureTryGetValue + direct store
Lightweight context initSkip GetTransportBinding for outbound dispatches when no transport correlation needed
Per-profile middleware bypassPre-computed _hasAnyNonRoutingMiddleware flag skips FrozenDictionary chain lookup
Single transport bus pre-resolutionPre-resolve single non-local bus at construction, bypass ConcurrentDictionary lookup
Routing decision cacheConcurrentDictionary<Type, RoutingDecision> for deterministic single-route types
Combined transport fast pathThe four transport optimizations above compose into a single pre-resolved outbound path

Memory Allocation Strategy​

Dispatch reduces allocations through:

  1. Object pooling for MessageContext
  2. ArrayPool<T> on batch-style paths
  3. Lazy initialization for optional context state
  4. ValueTask-based local fast paths
  5. Hot-path single-write patterns eliminating redundant dictionary allocations
  6. 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/.

See Also​