.NET 8’s TimeProvider and a single SynchronizationContext tweak can shave seconds off retry-logic unit tests that otherwise sit idle on real sleeps. A test that once took seven seconds now finishes in a few dozen milliseconds, and the change costs nothing extra to run in production.

Why the delay matters

Many retry helpers use exponential backoff: wait 1 second, then 2 seconds, then 4 seconds, and so on. In production that protects services from hammering a failing endpoint. In a test suite the same code calls Task.Delay on a real clock, so the test thread actually sleeps. Multiply that by dozens of tests and the continuous-integration (CI) pipeline inflates by minutes for no functional gain. The “sleep tax” is pure wasted time.

How TimeProvider works

.NET 8 introduced TimeProvider, an abstraction over the system clock. A class that needs the current time or needs to delay can accept a TimeProvider instance instead of calling DateTime.UtcNow or Task.Delay directly. In production you pass TimeProvider.System, which forwards to the real clock. In a test you supply a FakeTimeProvider. The fake lets you advance time manually with Advance(TimeSpan). Internally Task.Delay reads the provider, so moving the fake clock forward instantly satisfies any pending delay.

The idea is simple: replace the real clock with a controllable one, then jump ahead to the point where the code under test would have resumed.

The xUnit SynchronizationContext trap

The concept works in a console app, but in xUnit it caused a deadlock. The test called Advance() while the retry helper was awaiting a delay. xUnit installs its own SynchronizationContext that captures continuations and runs them on the test thread. When Advance() moved the clock forward, the continuation was queued to the thread pool instead of the test thread. The test thread kept advancing time, pushing the simulated clock past the moment when the next retry should fire. The new timer was set for a future point that would never be reached because no thread was left to move the clock again. The test hung indefinitely.

The one-line fix

Adding a single line at the start of the test restores the expected behavior:

SynchronizationContext.SetSynchronizationContext(null);

Clearing the custom context forces await continuations to run on the thread pool, where the fake timer’s callbacks can execute. With that change the same test drops from seven seconds to roughly 36 milliseconds.

Broader benefits

Beyond eliminating idle sleeps, a fake clock makes it easy to test time-zone-sensitive logic. You can verify that a daily quota resets at the correct local midnight without waiting for the real clock to strike twelve. The same approach works for any code that branches on the current time: inject the provider, avoid hidden calls to Thread.Sleep or Task.Delay, and you gain deterministic, fast tests.

What to watch for

  • Every delay must be routed through the injected TimeProvider. A stray Thread.Sleep or a direct Task.Delay will still invoke the real clock and re-introduce latency.
  • If a library you depend on internally calls Task.Delay without exposing a provider, you cannot control its timing without a more invasive shim. In such cases the benefit may be limited.
  • Grep the test folder for Task.Delay and Thread.Sleep to spot hidden waits. Refactoring those calls to use the provider is the only way to keep the fake clock effective.

Takeaway

Replacing the system clock with TimeProvider and disabling xUnit’s SynchronizationContext turns sluggish retry tests into near-instant checks, freeing CI resources and making time-dependent code easier to verify. The effort is a few lines of code; the payoff is measured in seconds saved per test run.