4 min read

Stress-tested against Polly

I ran NResilience and Polly 8 through a sustained-load stress suite for 32 minutes. Here are the numbers.

benchmarkstestingmethodology

TL;DR: I built a stress suite that runs NResilience and Polly 8 under sustained load - 90 configurations, three repetitions each, fresh processes. NResilience used 1.6x-2.4x less memory per call, spent about 2.3x less time paused in GC, and was 1.2x-1.8x faster where nothing retries. Where both libraries actually retry, it was a tie. The retry budget cut retries sent to a failing dependency by 4x-19x. Full report: bench/stress-results.md.

Why a stress suite?

Most benchmarks measure one call at a time: how many bytes, how many nanoseconds. That is useful, but it is not the same as 80 concurrent callers hitting a library against a dependency that is failing.

NResilience already has an allocation gate in CI to catch regressions, and a latency harness to trend per-operation cost. Neither answers the actual operator's question: what does this process sustain, and what does the dependency feel from the other side?

I built bench/NResilience.Stress to find out. It runs both libraries through the same harness under sustained load and measures throughput, memory, GC pauses, and how much load a guard keeps off a failing dependency. 90 configurations, three repetitions each, 270 runs in fresh processes, about 32 minutes.

The numbers

I paired each NResilience configuration with its closest Polly equivalent - same gate, same suspension count, same test callbacks.

Question Result
Memory per call 1.6x-2.4x less than Polly. 0% spread across repetitions, byte-identical in a second independent run.
Doing nothing With every feature off, 224 B/call - same as raw code with no library. Polly's empty pipeline costs 528 B/call.
Throughput 1.2x-1.8x faster where nothing retries. When both libraries actually retry: an exact tie.
GC pauses Full default policy allocates at 1.25 GB/s - Polly's empty pipeline allocates at 1.28 GB/s. Result: 7.6% of time paused in GC vs Polly's 17.3% at 10 workers.
Retry budget Against a dependency failing half its requests: 19x fewer retries sent, completing 53% as many operations. Failing a quarter: 4x fewer, completing 81%.
HTTP handler 1,273 B/request above transport vs a minimal Polly bridge's 1,209 B. Parity, marginally behind.
Tail latency Not measurable on this machine. No p99 claim is made.

Read the caveats before the scoreboard convinces you of something the run did not measure. No saturation was tested - 80 workers is high concurrency, not a saturated thread pool. Polly ran its cheapest configuration, pinned at 8.7.0. Throughput ratios moved 10-30% between runs and are quoted as ranges; anything without one should be read as "on this machine."

What was surprising

Deterministic allocations. I expected noisy medians. Instead: 0% spread across three repetitions, and a second, independent full run byte-for-byte identical. At the floor - every feature off - NResilience costs 224 B/call, exactly what the raw test callback allocates with no library at all. Turning NResilience off is free, byte-for-byte.

The retry tie. Both libraries at three attempts, zero delay: 1.00x-1.13x, a tie. I expected the retry loop itself to favor the flat executor. It does not - the advantage is in the frame around the call, not the retry loop. The win is not "we retry faster."

The p99 spread. Identical repetitions of the same cell produced p99s spanning 1.02 ms to 8.49 ms - a factor of 8 - with GC under 1% of the window. Multi-millisecond stalls of a whole process on a fanless laptop. That is the environment, not the library, and it means this run makes no tail-latency claim.

The budget's price. Against a dependency failing half its requests, the budget cut retries-per-operation 18.9x - and completed 53% as many operations, refusing the rest. Client-side availability for load relief on a shared dependency is a good trade across a fleet and a bad one for a single client with no fleet to protect.

How I kept it honest

Benchmarks are easy to rig by accident. A few decisions mattered:

  • Fresh process per test, so GC state belongs to exactly one library.
  • Shuffled order, so thermal drift does not land on one library.
  • Three repetitions, spreads published - a single run cannot support a claim that is a few percent.
  • The HTTP dependency in its own process, so its garbage does not pollute the client's numbers.

One caveat: Polly has no equivalent of the deadline, classifier, or retry budget, so in the "default machinery" pair NResilience is doing more work, not the same work. Both sides also ran their cheapest configuration - no telemetry on either. A claim that survives both of those notes is worth more than one that does not.

Try it yourself

The suite ships with the library:

dotnet run -c Release --project bench/NResilience.Stress -- --quick   # one DOP, one rate, single repetition
dotnet run -c Release --project bench/NResilience.Stress             # the full matrix, ~32 min

Reports land under artifacts/stress/ as timestamped Markdown and JSON. The JSON holds every repetition, not the medians - so anything asserted in the report can be recomputed from it. Run it on your hardware and the numbers will differ; throughput ratios here moved 10-30% between runs.

One machine, one run: a 10-core Apple Silicon laptop, .NET 10, Polly pinned at 8.7.0.

The full figures are in bench/stress-results.md. For how the same performance discipline is enforced per-change in CI, Shipping a resilience library covers the gates. For the executor the suite is measuring, One loop, one allocation explains the flat executor. The library and docs live at docs.nresilience.net - and if this is useful, a GitHub star helps others find it.