
Time is an external dependency. That may sound unusual at first because DateTime.UtcNow is available anywhere without configuration. This convenience becomes a problem as soon as business rules depend on the current moment: expired invitations, time-limited tokens, cancellation windows, rate limits, background jobs, and reminders.
Direct calls to DateTime.UtcNow mix two responsibilities. Business logic makes a decision while also retrieving the current time from the operating system. This is usually unobtrusive in production. In a test, however, it creates a hidden variable. A test running at midnight, during a slow CI execution, or at an expiration boundary can produce a different result than it did a few milliseconds earlier.
Since .NET 8, the runtime has provided TimeProvider, a standard abstraction for time. It does not replace a domain model for time, but it makes the time source explicit, replaceable, and therefore testable. This article covers the fundamentals, a practical ASP.NET Core integration, and tests without real waits.
Why DateTime.UtcNow can make tests flaky
A typical example is an invitation that remains valid for 24 hours. The obvious implementation is short:
1public sealed class Invitation
2{
3 public DateTime ExpiresAtUtc { get; init; }
4
5 public bool IsExpired()
6 {
7 return DateTime.UtcNow >= ExpiresAtUtc;
8 }
9}
This method can only be tested indirectly. A test must calculate an expiry time relative to the real system clock and hope that no relevant time passes between setup and execution. Tests at the boundary are particularly problematic:
1DateTime expiresAtUtc = DateTime.UtcNow.AddMilliseconds(1);
2Invitation invitation = new Invitation { ExpiresAtUtc = expiresAtUtc };
3
4bool isExpired = invitation.IsExpired();
The result depends on scheduling, machine load, and test execution. Thread.Sleep merely moves the uncertainty and makes tests slower. A custom IClock interface could solve the problem, but it also introduced another foundational abstraction, implementation, and convention to many projects.
TimeProvider puts this abstraction into the platform. Production code receives a real provider while tests receive a controllable one.
The core capabilities of TimeProvider
TimeProvider is more than a replacement for DateTime.UtcNow. The abstraction covers several kinds of time:
GetUtcNow()returns the current instant as aDateTimeOffsetin UTC.GetLocalNow()returns local time including its offset.GetTimestamp()andGetElapsedTime(...)measure elapsed time using a monotonic time source.CreateTimer(...)creates a timer bound to the provider.LocalTimeZoneexposes the local time zone.
The default implementation is TimeProvider.System. It uses DateTimeOffset.UtcNow for UTC time, the high-resolution Stopwatch source for timestamps, and the runtime’s normal timer infrastructure for timers.
The result of GetUtcNow() is deliberately a DateTimeOffset. An instant consists of a date, time, and offset. For values representing a specific moment, this is more robust than a DateTime with an ambiguous Kind. UTC remains the best default storage format for domain timestamps; a local representation belongs at the user-interface boundary or within a domain-defined time zone.
The smallest useful refactoring
A class receives TimeProvider in its constructor and uses it exclusively for the current time. The expiration itself remains a stored UTC instant.
1public sealed class InvitationService
2{
3 private readonly TimeProvider _timeProvider;
4
5 public InvitationService(TimeProvider timeProvider)
6 {
7 _timeProvider = timeProvider;
8 }
9
10 public Invitation Create(TimeSpan validity)
11 {
12 DateTimeOffset issuedAtUtc = _timeProvider.GetUtcNow();
13 DateTimeOffset expiresAtUtc = issuedAtUtc.Add(validity);
14
15 return new Invitation(issuedAtUtc, expiresAtUtc);
16 }
17
18 public bool IsExpired(Invitation invitation)
19 {
20 DateTimeOffset nowUtc = _timeProvider.GetUtcNow();
21 return nowUtc >= invitation.ExpiresAtUtc;
22 }
23}
24
25public sealed record Invitation(DateTimeOffset IssuedAtUtc, DateTimeOffset ExpiresAtUtc);
The service reads the clock in one place only. That makes it clear which decision depends on the present. It also benefits production code: logging, telemetry, and later analysis of the business rule can refer to the same instant rather than multiple slightly different time values.
Not every method needs a TimeProvider parameter. Dependencies belong at the boundary of a class or application component. Pure calculations are better modeled as functions with explicit inputs:
1public static bool IsExpired(DateTimeOffset nowUtc, DateTimeOffset expiresAtUtc)
2{
3 return nowUtc >= expiresAtUtc;
4}
This form is ideal when the calling process already knows the domain-relevant instant, such as a message receipt time or database timestamp. TimeProvider is then used only where the application truly needs to obtain the present.
Register TimeProvider in ASP.NET Core
In an ASP.NET Core application, the system provider is registered once in the composition root. All dependent services receive the same abstraction through dependency injection.
1using Microsoft.AspNetCore.Builder;
2using Microsoft.Extensions.DependencyInjection;
3
4WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
5
6builder.Services.AddSingleton(TimeProvider.System);
7builder.Services.AddScoped<InvitationService>();
8
9WebApplication app = builder.Build();
10
11app.MapPost("/invitations", (InvitationService invitationService) =>
12{
13 Invitation invitation = invitationService.Create(TimeSpan.FromHours(24));
14 return Results.Created($"/invitations/{invitation.IssuedAtUtc:O}", invitation);
15});
16
17app.Run();
TimeProvider.System is stateless and can therefore be registered as a singleton. A wrapper class is not required. Existing applications can migrate gradually: begin with code that has expiration rules or flaky tests. Replacing every DateTime use globally is neither necessary nor helpful.
For libraries targeting .NET 8 or later, TimeProvider is built into the framework. For .NET 5 through .NET 7, .NET Framework 4.6.2 and later, and .NET Standard 2.0, the Microsoft.Bcl.TimeProvider package provides the abstraction. The asynchronous helper APIs differ slightly on older target frameworks, so the concrete target frameworks should be checked before a broad migration.
Test expiration rules without real time
The largest practical gain appears in tests. The Microsoft.Extensions.TimeProvider.Testing package supplies FakeTimeProvider. It starts at a fixed instant and can be advanced under test control.
1<ItemGroup>
2 <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
3</ItemGroup>
The exact package version should match the .NET and Microsoft.Extensions version used by the project. With a fixed starting instant, an xUnit test can test the business rule without any relation to the real clock:
1using Microsoft.Extensions.Time.Testing;
2
3public sealed class InvitationServiceTests
4{
5 [Fact]
6 public void IsExpired_ReturnsFalse_BeforeTheExpiryTime()
7 {
8 DateTimeOffset startUtc = new DateTimeOffset(2026, 7, 25, 8, 0, 0, TimeSpan.Zero);
9 FakeTimeProvider timeProvider = new FakeTimeProvider(startUtc);
10 InvitationService service = new InvitationService(timeProvider);
11 Invitation invitation = service.Create(TimeSpan.FromHours(24));
12
13 timeProvider.Advance(TimeSpan.FromHours(23));
14
15 bool isExpired = service.IsExpired(invitation);
16
17 Assert.False(isExpired);
18 }
19
20 [Fact]
21 public void IsExpired_ReturnsTrue_AtTheExpiryTime()
22 {
23 DateTimeOffset startUtc = new DateTimeOffset(2026, 7, 25, 8, 0, 0, TimeSpan.Zero);
24 FakeTimeProvider timeProvider = new FakeTimeProvider(startUtc);
25 InvitationService service = new InvitationService(timeProvider);
26 Invitation invitation = service.Create(TimeSpan.FromHours(24));
27
28 timeProvider.Advance(TimeSpan.FromHours(24));
29
30 bool isExpired = service.IsExpired(invitation);
31
32 Assert.True(isExpired);
33 }
34}
The second test also documents the business boundary: with nowUtc >= expiresAtUtc, an invitation is no longer valid at its exact expiration instant. The rule is now readable and stable. There is no wait, tolerated millisecond range, or dependency on the time at which a CI pipeline runs.
FakeTimeProvider can also advance automatically whenever time is read. That option is useful for focused special cases, but Advance(...) is clearer for most business tests because it explicitly shows where time passes.
Test asynchronous delays as well
Time-dependent code does not consist only of comparisons. Retries, limited operations, and background processes commonly use Task.Delay, Task.WaitAsync, CancellationTokenSource, or timers. Since .NET 8, relevant APIs provide overloads accepting TimeProvider.
A service can encapsulate a due action without directly accessing the ordinary Task.Delay overload:
1public sealed class ReminderScheduler
2{
3 private readonly TimeProvider _timeProvider;
4
5 public ReminderScheduler(TimeProvider timeProvider)
6 {
7 _timeProvider = timeProvider;
8 }
9
10 public async Task WaitUntilAsync(DateTimeOffset dueAtUtc, CancellationToken cancellationToken)
11 {
12 DateTimeOffset nowUtc = _timeProvider.GetUtcNow();
13 TimeSpan delay = dueAtUtc - nowUtc;
14
15 if (delay > TimeSpan.Zero)
16 {
17 await Task.Delay(delay, _timeProvider, cancellationToken);
18 }
19 }
20}
The corresponding test starts the task, advances virtual time deliberately, and then checks completion. This is substantially faster and more reliable than waiting for a real minute.
1using Microsoft.Extensions.Time.Testing;
2
3public sealed class ReminderSchedulerTests
4{
5 [Fact]
6 public async Task WaitUntilAsync_Completes_WhenVirtualTimeReachesDueTime()
7 {
8 DateTimeOffset startUtc = new DateTimeOffset(2026, 7, 25, 8, 0, 0, TimeSpan.Zero);
9 FakeTimeProvider timeProvider = new FakeTimeProvider(startUtc);
10 ReminderScheduler scheduler = new ReminderScheduler(timeProvider);
11 DateTimeOffset dueAtUtc = startUtc.AddMinutes(15);
12
13 Task waitingTask = scheduler.WaitUntilAsync(dueAtUtc, CancellationToken.None);
14
15 Assert.False(waitingTask.IsCompleted);
16
17 timeProvider.Advance(TimeSpan.FromMinutes(15));
18
19 await waitingTask;
20 Assert.True(waitingTask.IsCompletedSuccessfully);
21 }
22}
An important boundary is visible here: only APIs that accept the TimeProvider use virtual time. A remaining Task.Delay(delay) or a third-party timer without a provider still waits in real time. Time must be passed through the entire relevant execution path as a dependency.
Separate calendar time from elapsed time
GetUtcNow() answers a question about an instant on the wall clock. That clock can change: NTP corrections, manual system-time changes, and virtual machines can move system time forward or backward. This is acceptable for an expiration timestamp because an invitation or token refers to a calendar instant.
For duration measurements, a wall clock is the wrong foundation. The difference between two DateTime.UtcNow calls can become negative or unexpectedly large after a clock correction. TimeProvider.GetTimestamp() and GetElapsedTime(...) use the monotonic Stopwatch source in TimeProvider.System and are better for duration, timeout budgets, and performance measurements.
1public sealed class OperationDurationTracker
2{
3 private readonly TimeProvider _timeProvider;
4
5 public OperationDurationTracker(TimeProvider timeProvider)
6 {
7 _timeProvider = timeProvider;
8 }
9
10 public async Task<TimeSpan> MeasureAsync(
11 Func<CancellationToken, Task> operation,
12 CancellationToken cancellationToken)
13 {
14 long startedAt = _timeProvider.GetTimestamp();
15
16 await operation(cancellationToken);
17
18 long completedAt = _timeProvider.GetTimestamp();
19 return _timeProvider.GetElapsedTime(startedAt, completedAt);
20 }
21}
The practical rule is straightforward: use GetUtcNow() for domain timestamps, use GetLocalNow() only when a local display or a local calendar rule is genuinely required, and use timestamps for measured duration. This separation prevents subtle mistakes involving time zones and clock corrections.
Timers need a lifecycle
TimeProvider.CreateTimer(...) creates an ITimer. It is useful for components that run at fixed intervals and must be triggered in a test without real waiting. The timer has a lifecycle and should be disposed like any other resource.
1public sealed class CacheRefreshWorker : IDisposable
2{
3 private readonly ITimer _timer;
4
5 public CacheRefreshWorker(TimeProvider timeProvider)
6 {
7 _timer = timeProvider.CreateTimer(
8 static state => ((CacheRefreshWorker)state!).Refresh(),
9 this,
10 TimeSpan.FromMinutes(5),
11 TimeSpan.FromMinutes(5));
12 }
13
14 public int RefreshCount { get; private set; }
15
16 public void Dispose()
17 {
18 _timer.Dispose();
19 }
20
21 private void Refresh()
22 {
23 RefreshCount++;
24 }
25}
In production code, timer callbacks should remain short, log failures, and avoid parallel executions of the same non-thread-safe work. A timer is not a replacement for a durable job infrastructure when persistence, distributed locking, retry strategies, or guaranteed execution are required. TimeProvider makes timers testable; it does not change their operational properties.
Migration strategy and common mistakes
A migration works best in small, domain-focused steps:
- Find direct calls to
DateTime.UtcNow,DateTimeOffset.UtcNow, and real delays. - Prioritize the code with expiration rules or unstable tests.
- Inject
TimeProviderinto the affected service and registerTimeProvider.Systemin the application. - Convert tests to
FakeTimeProviderand cover expiration boundaries explicitly. - Check asynchronous APIs for overloads accepting
TimeProvider.
Some errors remain possible after the refactoring. A DateTime with no clear Kind is not made correct automatically. Local time should not be stored as UTC. A provider should not be shared as mutable global test state, as parallel tests can then affect one another. A separate FakeTimeProvider instance for each test is the simple and safe option.
The abstraction should not be pushed too far either. A method that already receives a domain-specific timestamp does not need another clock dependency. Good time logic separates obtaining the present from calculations involving known instants.
Conclusion
TimeProvider replaces a hidden global access with an explicit platform dependency. This reduces flaky tests, removes artificial waits, and creates a clear vocabulary for time in code: a current domain instant, a local representation, elapsed duration, or a scheduled timer.
The smallest production step is registering TimeProvider.System and injecting it into time-dependent services. The greatest immediate benefit then appears in tests: FakeTimeProvider makes expiration boundaries, delays, and timers deterministic. In authentication, billing, background processing, and time-limited business rules, time becomes a deliberately modeled dependency instead of a difficult-to-control environmental variable.
Related articles

Jul 15, 2026 - 11 min read
Problem Details in ASP.NET Core and .NET 10
Error responses are part of an API contract. Still, many systems grow them incidentally: one endpoint returns { "error": "not …

Jul 14, 2026 - 10 min read
Using MeterListener with the new .NET 11 MemoryCache metrics
.NET 11 adds a small but useful improvement to the built-in observability story: MemoryCache can now publish OpenTelemetry-compatible …

Jul 10, 2026 - 11 min read
Top 10 recommendations for .NET developers working with Azure Cosmos DB
Azure Cosmos DB has a habit of exposing architectural weaknesses very early. Classic data-access patterns that work perfectly fine with …
Let's Work Together
Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.
