.NET

Stop calling DateTime.UtcNow: TimeProvider in .NET 8

By David López 7 min read

Any code that calls DateTime.UtcNow, Task.Delay or Stopwatch directly has a hidden dependency on the real clock. Testing it leaves you three bad options:

  • Wait. A retry with exponential backoff of 1, 2, 4 and 8 seconds takes 15 seconds per test run.
  • Shrink the numbers in tests. Then you're testing different values from the ones in production.
  • Don't test it. Expiry, timeouts and backoff are exactly where off-by-one bugs live.

.NET 8 added System.TimeProvider, a small abstract class for "what time is it" and "call me back later". Production code uses the real clock, and tests use a fake one that only moves when you tell it to.

Why not DateTime.Now in the first place

DateTime.Now is the server's local time, so the result depends on the machine's time zone and on daylight saving time. In Madrid on 25 October 2026, local time goes from 03:00 back to 02:00. Every local time between 02:00 and 02:59 happens twice, and a duration calculated with DateTime.Now across that change is off by an hour.

DateTime.UtcNow fixes that, but it's still a static call. You can't replace it in a test.

The API

Member Replaces
GetUtcNow() → DateTimeOffset DateTimeOffset.UtcNow / DateTime.UtcNow
GetLocalNow(), LocalTimeZone DateTimeOffset.Now, TimeZoneInfo.Local
GetTimestamp(), GetElapsedTime(start) Stopwatch.GetTimestamp(), Stopwatch.Elapsed
CreateTimer(callback, state, dueTime, period) → ITimer System.Threading.Timer
TimeProvider.System The real clock, a singleton

In .NET 8 the types that wait on time also take a TimeProvider:

await Task.Delay(TimeSpan.FromSeconds(5), timeProvider, ct);
await someTask.WaitAsync(TimeSpan.FromSeconds(10), timeProvider);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30), timeProvider);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5), timeProvider);

TimeProvider is part of .NET 8 and later. For .NET Framework 4.6.2+ and .NET Standard 2.0, the Microsoft.Bcl.TimeProvider package adds the same class, with the Task helpers as extension methods.

Production code: inject it

Register the real clock once:

builder.Services.AddSingleton(TimeProvider.System);

Then take it as a constructor dependency, the same way you would a repository. Here's a session that expires after 15 minutes:

public sealed record Session(string UserId, DateTimeOffset ExpiresAt);

public sealed class SessionStore(TimeProvider time)
{
    private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(15);

    public Session Create(string userId) => new(userId, time.GetUtcNow() + Ttl);

    public bool IsValid(Session session) => time.GetUtcNow() < session.ExpiresAt;
}

And a retry with exponential backoff. With baseDelay = 1 s, the delay before retry n is 2ⁿ⁻¹ s. Five attempts mean four waits: 1 + 2 + 4 + 8 = 15 s.

public static class Retry
{
    public static async Task<T> WithBackoffAsync<T>(
        Func<CancellationToken, Task<T>> operation,
        int maxAttempts,
        TimeSpan baseDelay,
        TimeProvider time,
        CancellationToken ct = default)
    {
        for (var attempt = 1; ; attempt++)
        {
            try
            {
                return await operation(ct);
            }
            catch (HttpRequestException) when (attempt < maxAttempts)
            {
                var delay = baseDelay * Math.Pow(2, attempt - 1);
                await Task.Delay(delay, time, ct);
            }
        }
    }
}

The only difference from the usual version is the time argument to Task.Delay.

Tests: FakeTimeProvider

The fake is in the Microsoft.Extensions.TimeProvider.Testing package, namespace Microsoft.Extensions.Time.Testing:

  • new FakeTimeProvider() starts at 2000-01-01 00:00:00 UTC, with the local time zone set to UTC. Pass a DateTimeOffset to start somewhere else.
  • Advance(TimeSpan) moves the clock forward and fires every timer that's now due. This includes the timers behind Task.Delay, CancellationTokenSource and PeriodicTimer.
  • SetUtcNow(DateTimeOffset) jumps to an exact time. SetLocalTimeZone(TimeZoneInfo) lets you test daylight saving time changes.
  • AutoAdvanceAmount moves the clock forward by a fixed amount after each read. That's useful when the code only compares timestamps.
  • GetTimestamp() and GetElapsedTime() follow the fake clock. Advance 250 ms, and GetElapsedTime returns exactly 250 ms.

Expiry: test the boundary, not "roughly 15 minutes"

[Fact]
public void Session_expires_after_15_minutes()
{
    var time = new FakeTimeProvider();
    var store = new SessionStore(time);
    var session = store.Create("user-1");

    time.Advance(TimeSpan.FromMinutes(14) + TimeSpan.FromSeconds(59));
    Assert.True(store.IsValid(session));

    time.Advance(TimeSpan.FromSeconds(1));
    Assert.False(store.IsValid(session));   // exactly at 15:00, expired
}

This test checks < against <= at the exact second. No real-clock test can do that reliably.

Backoff: check every delay

[Fact]
public async Task Retries_with_exponential_backoff()
{
    var time = new FakeTimeProvider();
    var attempts = new List<DateTimeOffset>();

    var task = Retry.WithBackoffAsync(_ =>
    {
        attempts.Add(time.GetUtcNow());
        return attempts.Count < 5
            ? Task.FromException<int>(new HttpRequestException())
            : Task.FromResult(42);
    }, maxAttempts: 5, baseDelay: TimeSpan.FromSeconds(1), time);

    Assert.Single(attempts);                   // first attempt runs immediately

    time.Advance(TimeSpan.FromMilliseconds(999));
    Assert.Single(attempts);                   // 1 ms before the first retry

    time.Advance(TimeSpan.FromMilliseconds(1));
    Assert.Equal(2, attempts.Count);           // t = 1 s

    time.Advance(TimeSpan.FromSeconds(2));     // t = 3 s
    time.Advance(TimeSpan.FromSeconds(4));     // t = 7 s
    time.Advance(TimeSpan.FromSeconds(8));     // t = 15 s

    Assert.Equal(42, await task);
    Assert.Equal(
        new[] { 0, 1, 3, 7, 15 },
        attempts.Select(a => (int)(a - attempts[0]).TotalSeconds));
}

On .NET 8 with xUnit, this test takes about 10 ms instead of 15 s, and it checks every delay to the millisecond. It works step by step because the fake operation returns completed tasks. Each Advance completes the pending Task.Delay, and the retry loop runs straight through to the next Task.Delay before Advance returns.

The trap: advancing before the timer exists

A timer's due time is now + delay, where now is the fake time when the timer is created. If the code under test does real asynchronous work before calling Task.Delay, your Advance can run first. The timer is then created later, relative to the time you already advanced to, and it never fires. The test hangs.

Make the fake operation truly asynchronous (await Task.Yield(); before it throws) and run the step-by-step test above. After advancing a total of 15 s, there has been only one attempt, and the task never completes.

When the dependency is really asynchronous, keep advancing until the task finishes:

public static class FakeTimeProviderExtensions
{
    public static async Task AdvanceUntilCompletedAsync(
        this FakeTimeProvider time, Task task, TimeSpan step, int maxSteps = 1_000)
    {
        for (var i = 0; i < maxSteps && !task.IsCompleted; i++)
        {
            time.Advance(step);
            await Task.Delay(1); // real time: let the code reach its next await
        }

        await task.WaitAsync(TimeSpan.FromSeconds(5)); // real-time guard, never hang CI
    }
}
await time.AdvanceUntilCompletedAsync(task, step: TimeSpan.FromSeconds(1));
Assert.Equal(42, await task);
Assert.Equal(5, attempts);

This version gives up exact timing. In a run with 1-second steps, the attempts landed at 0, 3, 5, 9 and 17 s instead of 0, 1, 3, 7 and 15 s: each attempt can come up to one step late. So assert on the outcome and the number of attempts, or on minimum gaps, not on exact times. If you need exact times, give the test a dependency that completes synchronously, as in the previous test.

Rules of thumb

  • Treat DateTime.Now, DateTime.UtcNow, DateTimeOffset.UtcNow, Stopwatch and the Task.Delay(TimeSpan) overload without a TimeProvider in domain code the way you'd treat new HttpClient(): something to inject instead.
  • Register TimeProvider.System as a singleton, and inject TimeProvider directly. It's already an abstract class, so an IClock wrapper adds nothing.
  • Test the edge: one tick before and exactly at every expiry, and every step of a backoff.
  • If a fake-time test hangs, check whether the timer was created before or after your Advance.
  • In ASP.NET Core 8, ISystemClock is obsolete. Authentication options take a TimeProvider instead.

David López
Software architect and developer. C#, .NET and AWS. Founder of FunTech Factory.

Keep reading

All articles