Skip to main content

Benchmarks

The RA.Utilities.Feature.Benchmarks project compares the generated mediator against MediatR 14.2 using BenchmarkDotNet, scenario for scenario. Earlier revisions ran the same suite against MediatR 12.5 (and, historically, against the pre-v11.1.0 runtime mediator); the tables below are the current dataset.

๐ŸŽฏ Methodologyโ€‹

  • Equal lifetimes. Both sides register their request handlers scoped, matching this package's documented lifetime, so the comparison measures dispatch machinery, not DI lifetime bookkeeping.
  • Fresh scope per iteration. Each iteration resolves the mediator from a new IServiceScope, mirroring per-request semantics.
  • Same payloads. Both sides share the same PongResponse and request shapes; handlers return a plain response (no Result wrapper).
  • Same pipeline depth. Depth is modeled with distinct request types per depth on both sides (depth is fixed by the DI registrations).
  • Null loggers everywhere. The behavior scenarios log through NullLoggerFactory-backed loggers on both sides, so each side measures its own behavior overhead, not a logging sink.
  • Same validation work. Both sides run identical FluentValidation rules through the same asynchronous ValidateAsync path, so the validation scenarios isolate dispatch, not validation style.
  • Hardware. Apple M1, .NET 10.0.11 (SDK 10.0.400), default job (โ‰ˆ15 warmup + โ‰ˆ20 target iterations per benchmark). All classes ran in one session on 2026-09-15, so machine conditions are identical within a table; absolute numbers shift between sessions โ€” compare within a run.

๐Ÿ“Š Results (send shapes)โ€‹

ScenarioMediatR 14.2Generated Mediator (concrete)Delta
Send, 0 behaviors333.2 ns / 608 B248.9 ns / 504 Bโˆ’25% time, โˆ’17% alloc
Send, 3 behaviors499.6 ns / 1136 B401.5 ns / 1000 Bโˆ’20% time, โˆ’12% alloc
Send, void283.9 ns / 512 B244.4 ns / 408 Bโˆ’14% time, โˆ’20% alloc
Send, with context343.3 ns / 664 B391.1 ns / 872 B+14% time, +208 B (see note)

Additional generated-mediator shapes (no MediatR counterpart at the same pipeline depth):

PathMeanAllocated
IMediator.Send<TRequest, TResponse>(...) (interface-typed)359.8 ns504 B
Mediator.Send((object)request) (object dispatch)289.7 ns504 B

MediatR reference points: 1 behavior at 428.3 ns / 848 B (the generated mediator runs a full 3-behavior pipeline faster than that) and 3 void behaviors at 475.7 ns / 1040 B.

Why the generated mediator winsโ€‹

  • Concrete handler injection โ€” the generator registers non-generic handlers as themselves, and the generated Send resolves the handler by concrete type and calls it (devirtualized), where MediatR walks a runtime request.GetType() dictionary into a cached wrapper and resolves through a non-generic GetService(Type) path. The benchmark handlers are generic (registered under their interfaces), so these measurements use the interface-resolution path โ€” plain non-generic handlers get the faster concrete path.
  • No delegate chain without behaviors โ€” with an empty pipeline the handler is invoked directly.
  • Hand-rolled reverse loop โ€” behavior composition avoids the LINQ Reverse().Aggregate(...) iterator and closure chain MediatR pays on every call.

The context caveatโ€‹

The context row is not apples-to-apples: MediatR has no PipelineContext<T>, so its benchmark carries the context data inside the request record (pre-created, zero extra allocation), while the generated mediator allocates a PipelineContext<T> and the T instance per call and runs a real context-aware behavior. On pure dispatch the two are at parity in that shape.

๐Ÿ“Š Results (notification fan-out)โ€‹

ScenarioMediatR 14.2Generated Mediator (concrete)Delta
Publish, 1 handler491.5 ns / 968 B285.4 ns / 624 Bโˆ’42% time, โˆ’36% alloc
Publish, 3 handlers751.9 ns / 1448 B367.0 ns / 896 Bโˆ’51% time, โˆ’38% alloc

The generated Publish fans out to handlers resolved directly from the provider with a per-handler behavior wrap โ€” no dictionary dispatch, no per-notification wrapper machinery.

๐Ÿ“Š Results (pipeline behaviors)โ€‹

The same comparison through the package's real request behaviors โ€” LoggingBehavior and the validation behavior wired by AddValidator โ€” against equivalent MediatR behaviors making the same log calls and running the same asynchronous validation work. The failing scenario measures the failure path: the behavior throws, and the benchmark catches it so the run stays green.

ScenarioMediatR 14.2Generated Mediator (concrete)Delta
Send, no behaviors328.2 ns / 608 B272.7 ns / 504 Bโˆ’17% time, โˆ’17% alloc
Send, logging532.8 ns / 984 B443.1 ns / 864 Bโˆ’17% time, โˆ’12% alloc
Send, validation (passing)2281.8 ns / 4576 B1948.3 ns / 4032 Bโˆ’15% time, โˆ’12% alloc
Send, validation (failing)17,607.4 ns / 10,409 B16,106.1 ns / 9088 Bโˆ’9% time, โˆ’13% alloc
Send, logging + validation2387.0 ns / 4824 B2030.6 ns / 4272 Bโˆ’15% time, โˆ’11% alloc
Send, void, logging + validation1994.7 ns / 4552 B1989.0 ns / 4000 B0% time, โˆ’12% alloc

Reading the numbers:

  • Logging is cheap โ€” the behavior adds ~170 ns / 360 B over the bare send on this package's side, with the log calls short-circuited by the null logger.
  • Validation cost is FluentValidation's โ€” ~1.7 ยตs / ~3.5 KB per passing request is the validator engine, not the wrapper; the dispatch advantage (โˆ’15%) survives it.
  • The failure path is exception-dominated โ€” ~16 ยตs of throw/catch on both sides, nearly identical across frameworks.
  • Behaviors are allocation-tuned โ€” the metrics behavior times with Stopwatch.GetTimestamp() (no per-call Stopwatch allocation), the retry behavior passes the handler delegate through without a closure, and validation snapshots validators with a single-validator fast path that skips the Task.WhenAll machinery (โ‰ˆ500 B less per validated request than the pre-tuning implementation).

๐Ÿ“Š Results (notification behaviors)โ€‹

The package's notification behaviors are Feature-only โ€” MediatR has no notification pipeline. One no-op handler per scenario (two for the fan-out baselines); the retry recovery scenario uses a zero backoff delay so it measures the retry machinery (catch, warn, loop), not a 200 ms backoff timer.

ScenarioMeanAllocated
Publish, 1 handler (baseline)285.4 ns624 B
Publish, logging497.6 ns880 B
Publish, metrics471.3 ns816 B
Publish, retry (succeeds first attempt)407.0 ns824 B
Publish, retry (succeeds second attempt)5,116.9 ns1112 B
Publish, logging + metrics + retry689.3 ns1176 B

Reading the numbers:

  • Each behavior costs ~120โ€“210 ns over the bare publish; the stacked pipeline (+404 ns / +552 B) is roughly the sum of its parts.
  • Retry's happy path is just the try/catch loop (+122 ns / +200 B); its recovery path costs ~4.8 ยตs โ€” dominated by the exception itself, not the retry loop.
  • Metrics is nearly free after the allocation tuning (+186 ns / +192 B, down from +232 B when it allocated a Stopwatch per publish).

๐Ÿ› ๏ธ Running the benchmarksโ€‹

# From the repository root; the env var disables the NuGet vulnerability audit
# for BenchmarkDotNet's inner autogenerated build.
NuGetAudit=false dotnet run -c Release \
--project Benchmarks/RA.Utilities.Feature.Benchmarks/RA.Utilities.Feature.Benchmarks.csproj \
--filter '*SendBenchmarks*' --job default
  • --filter '*SendBenchmarks*' โ€” the MediatR and generated-mediator send comparison.
  • --filter '*PublishBenchmarks*' โ€” the notification fan-out comparison.
  • --filter '*BehaviorBenchmarks*' โ€” the pipeline-behavior and notification-behavior scenarios (Feature and MediatR mirror).
  • --job short for a quick smoke run.

The benchmark sources live in Benchmarks/RA.Utilities.Feature.Benchmarks/:

FolderContents
Shared/DiHost scope factory, the shared payload types, and BenchmarkLogging (null-logger registration)
Comparison/MediatR handlers, behaviors, requests, validators, and benchmarks
Feature/The generated-mediator scenarios: handlers, closed no-op behaviors, the package-behavior scenarios (requests, handlers, validators, BehaviorServices), and the benchmark classes

๐Ÿง  Summaryโ€‹

At equal lifetimes against MediatR 14.2, the generated mediator is 14โ€“25% faster with 12โ€“20% fewer allocations across the send shapes, 42โ€“51% faster on notification fan-out, and 9โ€“17% faster through equivalent logging/validation pipelines (the void pipeline at time parity, โˆ’12% allocations). Interface-typed dispatch is within ~45% of the concrete path, and object-based dispatch costs about the same as a monomorphic send. The package's own behaviors add single-digit hundreds of nanoseconds each and stack additively. See the Generated Mediator guide for the dispatch matrix behind these numbers.