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-19 05:59 UTC (commit 017cc61).

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 — async14.7 ns57.4 ns0 B0 B3.5× faster
Empty pipeline — reference-state baseline14.6 ns—0 B——
Empty pipeline — caller-seeded context64.3 ns—0 B——
EmptyOutcomeState10.0 ns—0 B——
EmptyTaskOutcomeState12.6 ns—0 B——
Empty pipeline — zero-closure state overload25.8 ns61.5 ns0 B0 B3.6× faster
Empty pipeline — sync9.6 ns29.5 ns0 B0 B3.2× faster
NestedEmptyAsync103 ns—0 B——
NestedEmptySync91.9 ns—0 B——
NestedWithPropertiesAsync160 ns—0 B——
NestedWithPropertiesSync151 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 attempt134 ns265 ns0 B24 B1.9× faster
Retry(3) — two failures then success4.32 μs4.58 μ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 instantly174 ns215 ns0 B0 B1.2× faster
SynchronousGenerator_HappyPath229 ns—0 B——
AsynchronousGenerator_HappyPath1.62 μ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.62 μs5.65 μs1.3 KB1.3 KBon par
Isolated circuit — fast-fail rejection via ExecuteOutcomeAsync108 ns182 ns144 B200 B1.7× faster
Ratio breaker, closed — success186 ns274 ns0 B24 B1.5× faster
DynamicDurationConfigured207 ns—0 B——
AsyncCallbackConfigured210 ns—0 B——
SlowCallDetectionConfigured299 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.7 ns199 ns144 B200 B3.2× faster

Fallback​

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

ScenarioKevlarPollyKevlar allocatedPolly allocatedKevlar vs Polly
NoNotification2.47 μs—200 B——
CompletedAsyncNotification2.48 μs—200 B——
YieldingAsyncNotification6.46 μs—811 B——
Fallback — not triggered131 ns143 ns0 B0 Bon par
SynchronousDelegate_Triggered2.44 μs—200 B——
Fallback — triggered by exception2.53 μs2.50 μs200 B256 Bon par
EmptyVoid37.8 ns—0 B——
VoidPassThrough174 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.55 μs71.18 μs1.3 KB41.4 KB12× faster
Budget exhausted — rejection via ExecuteOutcomeAsync154 ns47.96 μs136 B35.5 KB282× faster
Token bucket — uncontended acquire152 ns157 ns0 B0 Bon par
WithHooks_Uncontended152 ns—0 B——
FrameworkAdapter_Uncontended147 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.49 μs66.31 μs1.3 KB39.5 KB11× faster
Permits exhausted — rejection via ExecuteOutcomeAsync103 ns42.45 μs136 B33.6 KB389× faster
Concurrency limit — uncontended145 ns205 ns0 B40 B1.4× faster
WithHooks_Uncontended149 ns—0 B——
Adaptive_Uncontended224 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 retry132 ns199 ns0 B0 B1.5× faster

Composed pipelines​

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

ScenarioKevlarPollyKevlar allocatedPolly allocatedKevlar vs Polly
Timeout → Retry → ratio breaker338 ns692 ns0 B48 B1.9× faster
Token bucket → Timeout → Retry → ratio breaker → Concurrency limit474 ns998 ns0 B88 B2.0× faster
TokenBucketRatioFiveStrategyChainSync481 ns1.02 μs0 B88 B2.2× faster

Environment​

  • AMD EPYC 7763
  • .NET 10.0.13 (10.0.13, 10.0.1326.50504), 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.