Skip to main content

Benchmarks

Kevlar vs Polly v8 across every strategy, measured with BenchmarkDotNet on GitHub Actions and republished automatically by the benchmarks workflow.

Last updated 2026-10-05 04:27 UTC (commit a78926f).

note

Microbenchmarks on shared CI runners are noisy. The vs Polly column is the median ratio over the most recent runs (up to 10); anything within ±20% is reported as on par. Absolute times move with runner hardware — the ratios are the signal.

Pipeline overhead floor​

What an empty pipeline costs per execution — the fixed tax every strategy builds on.

ScenarioKevlarPollyKevlar allocatedPolly allocatedKevlar vs Polly
Empty pipeline — async18.6 ns59.9 ns0 B0 B3.6× faster
Empty pipeline — reference-state baseline17.4 ns—0 B——
Empty pipeline — caller-seeded context66.1 ns—0 B——
EmptyOutcomeState10.0 ns—0 B——
EmptyTaskOutcomeState12.5 ns—0 B——
Empty pipeline — zero-closure state overload14.9 ns60.7 ns0 B0 B4.1× faster
Empty pipeline — sync9.5 ns28.6 ns0 B0 B3.1× faster
NestedEmptyAsync105 ns—0 B——
NestedEmptySync96.0 ns—0 B——
NestedWithPropertiesAsync159 ns—0 B——
NestedWithPropertiesSync160 ns—0 B——

Retry​

Happy path (judge overhead only) and a recovery path where every call fails twice before succeeding, with backoff disabled so only strategy machinery is measured.

ScenarioKevlarPollyKevlar allocatedPolly allocatedKevlar vs Polly
Retry(3) — success on first attempt140 ns258 ns0 B24 B1.8× faster
Retry(3) — two failures then success4.23 μs4.44 μs192 B328 Bon par

Timeout​

The timeout never fires; this is the cost of arming and disarming the cancellation plumbing on every call.

ScenarioKevlarPollyKevlar allocatedPolly allocatedKevlar vs Polly
Timeout(10 s) — completes instantly170 ns218 ns0 B0 Bon par
SynchronousGenerator_HappyPath217 ns—0 B——
AsynchronousGenerator_HappyPath1.67 μs—560 B——
AsyncHookConfigured_HappyPath212 ns—0 B——

Circuit breaker​

Ratio/sampling bookkeeping while closed, and the fast-fail rejection cost while manually isolated — caught as a thrown exception, and read from the no-throw ExecuteOutcomeAsync API.

ScenarioKevlarPollyKevlar allocatedPolly allocatedKevlar vs Polly
Isolated circuit — fast-fail rejection thrown5.46 μs5.55 μs1.3 KB1.3 KBon par
Isolated circuit — fast-fail rejection via ExecuteOutcomeAsync115 ns180 ns144 B200 B1.6× faster
Ratio breaker, closed — success191 ns269 ns0 B24 B1.4× faster
DynamicDurationConfigured209 ns—0 B——
AsyncCallbackConfigured210 ns—0 B——
SlowCallDetectionConfigured303 ns—0 B——

Circuit breaker under contention​

Eight workers share one open circuit, so every call is a fast-fail rejection observed as an outcome; this is the per-call cost during an outage.

ScenarioKevlarPollyKevlar allocatedPolly allocatedKevlar vs Polly
Open circuit — 8 workers rejected65.1 ns201 ns144 B200 B3.2× faster

Fallback​

Pass-through when the execution succeeds, and substitution when it throws.

ScenarioKevlarPollyKevlar allocatedPolly allocatedKevlar vs Polly
NoNotification2.51 μs—200 B——
CompletedAsyncNotification2.46 μs—200 B——
YieldingAsyncNotification6.46 μs—798 B——
Fallback — not triggered133 ns135 ns0 B0 Bon par
SynchronousDelegate_Triggered2.33 μs—200 B——
Fallback — triggered by exception2.47 μs2.56 μs200 B256 Bon par
EmptyVoid37.9 ns—0 B——
VoidPassThrough173 ns—0 B——

Rate limit​

Uncontended token-bucket permit acquisition — every call is admitted — and the rejection cost once the budget is exhausted, thrown vs ExecuteOutcomeAsync.

ScenarioKevlarPollyKevlar allocatedPolly allocatedKevlar vs Polly
Budget exhausted — rejection thrown5.44 μs70.84 μs1.3 KB41.4 KB13× faster
Budget exhausted — rejection via ExecuteOutcomeAsync151 ns46.33 μs136 B35.5 KB305× faster
Token bucket — uncontended acquire155 ns164 ns0 B0 Bon par
WithHooks_Uncontended151 ns—0 B——
FrameworkAdapter_Uncontended142 ns—0 B——
PartitionedFrameworkAdapter_Uncontended186 ns—32 B——

Concurrency limit​

A single caller against a large permit count; acquire/release cost with no queueing. The rejection rows hold the only permit and compare thrown vs ExecuteOutcomeAsync.

ScenarioKevlarPollyKevlar allocatedPolly allocatedKevlar vs Polly
Permits exhausted — rejection thrown5.36 μs65.21 μs1.3 KB39.5 KB12× faster
Permits exhausted — rejection via ExecuteOutcomeAsync98.8 ns42.70 μs136 B33.6 KB432× faster
Concurrency limit — uncontended146 ns221 ns0 B40 B1.3× faster
WithHooks_Uncontended151 ns—0 B——
Adaptive_Uncontended216 ns—0 B——

Typed result handling​

Retry configured to treat a sentinel result as a failure; the returned value never matches, so this is the per-call cost of judging results.

ScenarioKevlarPollyKevlar allocatedPolly allocatedKevlar vs Polly
Typed retry — result judged, no retry141 ns269 ns0 B0 B1.4× faster

Composed pipelines​

How per-call overhead scales with pipeline depth when nothing goes wrong.

ScenarioKevlarPollyKevlar allocatedPolly allocatedKevlar vs Polly
Timeout → Retry → ratio breaker331 ns689 ns0 B48 B1.8× faster
Token bucket → Timeout → Retry → ratio breaker → Concurrency limit476 ns1.02 μs0 B88 B1.9× faster
TokenBucketRatioFiveStrategyChainSync478 ns1.02 μs0 B88 B2.1× faster

Environment​

  • AMD EPYC 7763
  • .NET 10.0.12 (10.0.12, 10.0.1226.42308), BenchmarkDotNet 0.15.8
  • Times are medians; allocations are per operation.

Reproduce​

dotnet run -c Release --project benchmarks/Kevlar.Benchmarks -- --filter '*'

As always with microbenchmarks: measure your own workload before optimizing around these numbers. Nanosecond differences matter in tight loops and high-throughput services; they don't matter around a 50 ms network call.