4 min readUnder the hood

One loop, one allocation

A deep dive into the NResilience flat executor architecture, its allocation budgets, and why a single loop is faster than a layered pipeline.

performanceinternals

TL;DR: Most resilience libraries use a layered pipeline of strategies, where every layer adds a new state machine and new allocations. NResilience uses a "flat executor" - a single async loop that handles retries, deadlines, and breakers in one frame. This reduces the per-call overhead to a constant floor, regardless of policy complexity, and enables full Native AOT support.

The Problem: The Layered Tax

In a traditional resilience pipeline, you stack strategies like a cake: Retry -> Timeout -> Circuit Breaker -> Callback.

In .NET, every async method that suspends (waits) heap-allocates a state machine "box" to track its progress. In a layered architecture, each strategy is its own async method. This means if you have three layers of resilience, you pay the allocation tax three times for every single call.

As you add more complex policies - hedging, adaptive limits, or nested retries - the allocation cost grows linearly. This creates GC pressure that manifests as tail-latency spikes (p99), which is exactly what resilience libraries are supposed to prevent.

The Solution: The Flat Executor

NResilience rejects the pipeline model. Instead of a chain of strategies, it uses a Flat Executor.

The core of the library is a single async loop. This loop handles the entire lifecycle of the call - admission, deadline checks, attempt looping, backoff, and classification - inside one method frame.

One Frame, One Box

Because there is only one main async method, there is only one state-machine allocation per call. Whether your policy is a simple retry or a complex combination of adaptive limits, budgets, and deadlines, the overhead remains constant.

The cost is not based on how many settings you have, but on the execution path taken.

Measured Allocation Budgets

We don't guess about performance; we measure it in CI. NResilience enforces strict allocation ceilings using GC.GetAllocatedBytesForCurrentThread() in Release builds.

Execution Path Allocation Cost Why?
Resilience.None 0 B The executor returns the callback's own task.
Sync-completing, full policy 72 B The "floor": one linked CancellationTokenSource per attempt, because handing out the pooled source's own token is a data race.
Suspending, full policy 448 B The state-machine box for a call that actually suspends.
Hedged call (no hedge fired) ~1,370 B The "hedge tax": tracking multiple in-flight legs.

By "quarantining" the higher cost of hedging, standard callers never pay for features they aren't using. If you don't hedge, you stay at the floor. Every figure is a ceiling enforced by CI, with roughly 15% headroom for cross-architecture variation - and NResilience deliberately does not claim to be "zero allocation" in an unqualified sense. The claim is one box instead of one box per layer, and the build gates enforce it.

These budgets hold under sustained load, too. The stress suite measured a whole policy end to end against Polly: with every feature off, NResilience allocates 224 B/call - byte-for-byte what the raw test callback allocates with no library at all - while Polly's empty pipeline adds 304 B. The full default policy sits at 624 B/call, and the process spends about 2.3x less time paused in GC than Polly's retry + timeout pipeline. The full run, with every repetition published, is in Stress-tested against Polly.

Native AOT and the AOT Probe

The flat executor's design makes it a natural fit for Native AOT. Layered pipelines often rely on reflection or complex generic compositions that can trip up AOT compilers or lead to oversized binaries.

NResilience is reflection-free. To ensure this stays true, our CI pipeline includes an AOT Probe:

  1. It publishes the library with PublishAot=true.
  2. It treats all AOT warnings as errors.
  3. It executes the resulting native binary to verify that DI, configuration binding, and telemetry still work.

This means you can ship NResilience in a tiny, fast-starting native binary without worrying about runtime crashes due to missing metadata.

The Trade-off: Composition vs. Performance

The cost of the flat executor is a loss of "compositional extensibility." In a pipeline library, you can write a custom IStrategy class and plug it into the middle of the chain. In NResilience, you cannot.

To solve this without breaking the flat loop, we provide Extension Hooks. These are specific, high-leverage points where you can inject logic without adding new async frames:

  • Admit: A hook to decide if a call should proceed before the attempt starts.
  • BeforeAttempt: Setup logic that runs before every attempt, including the first; its time counts toward the deadline.
  • OnEvent: A stateless, allocation-free listener for logging and telemetry.
  • Backoff.Custom: A delegate to compute the delay between attempts.

You get the customization you need, but the library keeps the performance of a single loop.

Conclusion

Resilience should not come at the cost of performance. By replacing the layered pipeline with a flat executor, NResilience ensures that adding safety doesn't add latency. One loop, one allocation, and zero reflection.

The flat executor exposes specific extension hooks instead of a general strategy pipeline. Bending the library covers Admit, BeforeAttempt, Backoff.Custom, and OnEvent - the targeted escape hatches that let you inject custom logic without breaking the performance model.