Time and Timer Abstraction (TimeProvider)

July 16, 2026 · View on GitHub

When to read this: Read this when adopting System.TimeProvider across the stack (replacing direct DateTime.UtcNow, Timer, and similar timing primitives in custom NodeManagers, durable subscriptions, and reconnect policies).

Not source-breaking. The stack now uses System.TimeProvider as its canonical clock and scheduler so that timeouts, intervals, keep-alive loops, reconnect back-off, publishing pacing, certificate-lifetime checks, and similar duration-sensitive code paths are mockable in tests and immune to wall-clock changes.

HiResClock is still in place but every public member is now marked [Obsolete]. The class itself is not obsolete so that existing field references (HiResClock.Disabled) keep round-tripping through configuration; only the static clock-reading members raise CS0618. The recommended replacements are:

Legacy APIReplacement
HiResClock.UtcNowtimeProvider.GetUtcNow().UtcDateTime
HiResClock.TickCount64 / .TickstimeProvider.GetTimestamp()
HiResClock.TickCount (int wraparound)timeProvider.GetTickCount() (internal extension in Opc.Ua)
HiResClock.UtcTickCount(offsetMs)timeProvider.GetTimestampMilliseconds() + offsetMs
elapsed-time math via TickCountlong start = timeProvider.GetTimestamp(); … TimeSpan elapsed = timeProvider.GetElapsedTime(start);
new Stopwatch() / Stopwatch.StartNew() for durationlong start = timeProvider.GetTimestamp(); … timeProvider.GetElapsedTime(start);
new System.Threading.Timer(…)ITimer timer = timeProvider.CreateTimer(callback, state, dueTime, period);
Task.Delay(delay, ct) in production timing loopsTask.Delay(delay, timeProvider, ct)
new CancellationTokenSource(timeout)new CancellationTokenSource(timeout, timeProvider)

Constructor pattern. Components that need a clock now take a nullable TimeProvider as the last constructor parameter with a default value of null. If null is passed, TimeProvider.System is used. Example:

public sealed class Foo
{
    private readonly TimeProvider m_timeProvider;

    public Foo(/* existing args */, TimeProvider? timeProvider = null)
    {
        // existing initialisation…
        m_timeProvider = timeProvider ?? TimeProvider.System;
    }
}

For published public types whose existing constructors must remain binary-compatible, the original constructor signature is preserved and a new overload that ends with TimeProvider? is added. The legacy constructor delegates to the new one passing timeProvider: null. No existing constructor is marked [Obsolete] in this release.

Dependency injection. AddOpcUaServerBuilder / AddOpcUaClientBuilder register TimeProvider.System via TryAddSingleton<TimeProvider> and wire the resolved provider into every component they construct. To run a server or client against a fake clock in tests, register a Microsoft.Extensions.Time.Testing.FakeTimeProvider in the service collection before the OPC UA builders.

services.AddSingleton<TimeProvider>(new FakeTimeProvider());
services.AddOpcUaServerBuilder(/* … */);

Outside DI, pass the TimeProvider directly to the type's constructor as the last argument.

Migrating off HiResClock. Replace the call with the table above. If the migration cannot happen immediately, wrap the affected scope with #pragma warning disable CS0618 / #pragma warning restore CS0618.

// before:
long start = HiResClock.TickCount64;
DoWork();
TimeSpan elapsed = TimeSpan.FromTicks(HiResClock.TickCount64 - start);

// after:
long start = m_timeProvider.GetTimestamp();
DoWork();
TimeSpan elapsed = m_timeProvider.GetElapsedTime(start);
// before:
DateTime utcNow = HiResClock.UtcNow;

// after — when a wall-clock value is required (e.g. for an OPC UA SourceTimestamp):
DateTime utcNow = m_timeProvider.GetUtcNow().UtcDateTime;
// before:
m_timer = new Timer(OnTick, state: null, dueTime: 1_000, period: Timeout.Infinite);

// after:
m_timer = m_timeProvider.CreateTimer(OnTick, state: null,
    dueTime: TimeSpan.FromMilliseconds(1_000), period: Timeout.InfiniteTimeSpan);

The Timer field type changes from System.Threading.Timer to ITimer — both implement IDisposable and the same Change / Dispose semantics; only the parameter types on Change differ (TimeSpan instead of int/uint/long).

Monotonic timestamps for duration calculations

TimeProvider.GetTimestamp() returns a long monotonic timestamp that does not suffer from the 32-bit wraparound of Environment.TickCount / HiResClock.TickCount nor the system-clock drift of DateTime.UtcNow. All internal duration math in the stack now uses GetTimestamp() + GetElapsedTime(start) instead of int-tick subtraction. The following public surface changes were made:

Old (removed or [Obsolete])New
ISession.LastKeepAliveTickCount: int (was on the interface)ISession.LastKeepAliveTimestamp: long + timeProvider.GetElapsedTime(timestamp) (legacy int now an [Obsolete] extension property in SessionObsolete)
ChannelToken.Expired, ChannelToken.ActivationRequired, ChannelToken.CreatedAtTickCountRemoved. Use ChannelToken.IsExpired(TimeProvider) / ChannelToken.IsActivationRequired(TimeProvider) (internal).
UaSCUaBinaryChannel.LastActiveTickCount: int (protected)Removed. Use UaSCUaBinaryChannel.GetElapsedSinceLastActive(): TimeSpan (internal).

Pattern for new code computing an internal duration:

// before:
int startTicks = m_timeProvider.GetTickCount();
// ... do work ...
int elapsedMs = m_timeProvider.GetTickCount() - startTicks;

// after:
long startTimestamp = m_timeProvider.GetTimestamp();
// ... do work ...
TimeSpan elapsed = m_timeProvider.GetElapsedTime(startTimestamp);

See also