Start Debugging

IOptions<T> vs IOptionsSnapshot<T> vs IOptionsMonitor<T> in .NET 11

Default to IOptions<T>. Use IOptionsMonitor<T> when a singleton has to see config reloads, and IOptionsSnapshot<T> only when a scoped consumer wants a value that is stable for one request. The deciding axis is the lifetime of the consumer, not the shape of the settings.

Inject IOptions<T> unless you have a concrete reason not to. It is a singleton, it binds your settings class exactly once for the life of the process, and it is the cheapest of the three to resolve. Reach for IOptionsMonitor<T> when a long-lived service has to observe configuration changes without a restart, and for IOptionsSnapshot<T> in one narrow case: a scoped or transient consumer that wants a value which is stable for the duration of a single request but is allowed to differ between requests. The axis that decides this is the lifetime of the class doing the injecting, not the shape of the settings being injected. Everything below targets .NET 11 (tested against Preview 6, SDK 11.0.100-preview.6.26359.118) and C# 14, with Microsoft.Extensions.Options 11.0.0. The three interfaces have behaved this way since .NET Core 2.0, so all of it runs unchanged on .NET 10 GA; the only genuinely new material is the .NET 11 validation work at the end.

The feature matrix

FeatureIOptions<T>IOptionsSnapshot<T>IOptionsMonitor<T>
Concrete implementationUnnamedOptionsManager<T>OptionsManager<T>OptionsMonitor<T>
DI lifetimeSingletonScopedSingleton
Injectable into a singletonYesNo, captive dependencyYes
Sees a config reloadNo, everYes, on the next scopeYes, immediately
Named optionsNoYes, Get(name)Yes, Get(name)
Change callbacksNoNoYes, OnChange
Value accessor.Value.Value, .Get(name).CurrentValue, .Get(name)
How often the binder runsOnce per processOnce per scope, per nameOnce per change, per name
Where the instance is cachedField on the singletonOptionsCache<T> inside the scoped managerSingleton IOptionsMonitorCache<T>

Two rows carry most of the weight. The lifetime row is the one that produces startup exceptions, and the “how often the binder runs” row is the one that produces surprise CPU on a hot path. Everything else follows from those two.

All three are registered by AddOptions(), which the host calls for you. From OptionsServiceCollectionExtensions:

// Microsoft.Extensions.Options 11.0.0 -- what AddOptions() actually registers
services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptions<>), typeof(UnnamedOptionsManager<>)));
services.TryAdd(ServiceDescriptor.Scoped(typeof(IOptionsSnapshot<>), typeof(OptionsManager<>)));
services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitor<>), typeof(OptionsMonitor<>)));
services.TryAdd(ServiceDescriptor.Transient(typeof(IOptionsFactory<>), typeof(OptionsFactory<>)));
services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitorCache<>), typeof(OptionsCache<>)));

Note that IOptionsFactory<T> is transient and does the real work: it runs every registered IConfigureOptions<T> in order, then every IPostConfigureOptions<T>, then validation. The three accessor interfaces differ only in how aggressively they cache the factory’s output. That is the whole story, and it is why the choice is about lifetime.

The settings class and registration are identical for all three:

// .NET 11, C# 14
public sealed class PaymentOptions
{
    public string ApiKey { get; set; } = "";
    public int TimeoutSeconds { get; set; } = 30;
}

// Program.cs
builder.Services.Configure<PaymentOptions>(
    builder.Configuration.GetSection("Payment"));

When to pick IOptions

Make this the default. You give up reload support, and in most services that is not a real loss.

// .NET 11, C# 14
public sealed class PaymentClient(IOptions<PaymentOptions> options)
{
    // Safe: the value is fixed for the life of the process.
    private readonly PaymentOptions _settings = options.Value;

    public TimeSpan Timeout => TimeSpan.FromSeconds(_settings.TimeoutSeconds);
}

The cost of IOptions<T> is exactly one thing: it does not support named options, so Configure<Features>("Personalize", ...) is invisible to it. If you need two configurations of the same class, you have already ruled IOptions<T> out. That is also the moment to check whether keyed services in .NET 11 dependency injection are a better fit than named options for what you are actually modelling.

When to pick IOptionsSnapshot

Reach for it when a scoped consumer needs a value that stays consistent for one unit of work but is allowed to move between units of work.

// .NET 11, C# 14 -- scoped service, values stable for this request
public sealed class CheckoutService(IOptionsSnapshot<PaymentOptions> snapshot)
{
    private readonly PaymentOptions _settings = snapshot.Value;

    public string Key => _settings.ApiKey;
}

Two hard limits. First, IOptionsSnapshot<T> is registered Scoped, so injecting it into a singleton fails, including into an IHostedService or BackgroundService, which are singletons. The host turns on ValidateScopes and ValidateOnBuild in the Development environment, so you get a clear Cannot consume scoped service at startup there; outside Development those checks are off by default, and the same code resolves a captive dependency that quietly never refreshes. Turn scope validation on everywhere if you want the failure to be loud. The workaround is creating a scope inside the BackgroundService and resolving from there, but if all you wanted was fresh values, IOptionsMonitor<T> is the simpler answer. Second, there is no ambient scope in a console app or a raw IHost unless you make one, so IOptionsSnapshot<T> in a non-web host almost always means you actually wanted IOptionsMonitor<T>.

When to pick IOptionsMonitor

Reach for it when a singleton has to see changes, or when you need a callback.

OptionsMonitor<T> subscribes to every registered IOptionsChangeTokenSource<T>. When one fires, InvokeChanged runs _cache.TryRemove(name), immediately rebuilds with TOptions options = Get(name), and then invokes the listeners with the new instance. CurrentValue is a thin wrapper over Get(Options.DefaultName), which is _cache.GetOrAdd(localName, () => localFactory.Create(localName)).

// .NET 11, C# 14 -- singleton, always current
public sealed class RateLimiter : IDisposable
{
    private readonly IDisposable? _subscription;
    private volatile PaymentOptions _current;

    public RateLimiter(IOptionsMonitor<PaymentOptions> monitor)
    {
        _current = monitor.CurrentValue;
        _subscription = monitor.OnChange(updated => _current = updated);
    }

    public int TimeoutSeconds => _current.TimeoutSeconds;

    public void Dispose() => _subscription?.Dispose();
}

That IDisposable matters. OnChange returns a ChangeTrackerDisposable whose Dispose runs _monitor._onChange -= OnChange. Register a callback from a scoped or transient service and throw the return value away, and every request adds a listener to a singleton’s multicast delegate that never comes off. The result is a slow leak plus a callback storm, and it is one of the most common ways an IOptionsMonitor<T> goes wrong.

Change notifications only exist for file-system configuration providers such as Microsoft.Extensions.Configuration.Json, .Ini, .Xml, .KeyPerFile, and .UserSecrets, and only when the provider was added with reloadOnChange: true. An environment-variable or command-line provider never fires, so on those sources IOptionsMonitor<T> degrades silently into a slightly more expensive IOptions<T>.

The measurement that matters is a count, not a nanosecond figure

I am deliberately not publishing ns/op numbers here, because the resolve cost of all three is dominated by whatever your own IConfigureOptions<T> delegates and validators do, which means my machine’s numbers would tell you nothing about yours. The number that is portable is how many times your binder runs, and you can measure that in about fifteen lines.

// .NET 11 Preview 6, C# 14 -- counts how often the options are actually built
public sealed class CountingConfigure : IConfigureOptions<PaymentOptions>
{
    public static int Count;
    public void Configure(PaymentOptions options) => Interlocked.Increment(ref Count);
}

builder.Services.AddSingleton<IConfigureOptions<PaymentOptions>, CountingConfigure>();

app.MapGet("/probe", (
    IOptions<PaymentOptions> o,
    IOptionsSnapshot<PaymentOptions> s,
    IOptionsMonitor<PaymentOptions> m) =>
{
    _ = o.Value; _ = s.Value; _ = m.CurrentValue;
    return CountingConfigure.Count;
});

Hit /probe repeatedly and the counter climbs by exactly one per request, and that one is the IOptionsSnapshot<T>. IOptions<T> contributes on the first request only, IOptionsMonitor<T> contributes on the first request and then once per reload, and IOptionsSnapshot<T> contributes on every single request because a fresh scope means a fresh OptionsManager<T> with an empty OptionsCache<T>. Add .ValidateDataAnnotations() to that registration and the validators re-run on every request too. On an endpoint doing 5,000 requests per second, that is 5,000 rebinds and 5,000 validation passes per second for a value that changes roughly never. This is the concrete reason IOptionsSnapshot<T> should not be your default, and it is a claim you can verify in your own app rather than take from a chart.

The gotchas that pick for you

OnChange fires for configuration you do not care about. Callbacks are wired to the configuration root’s change token, not to your section. Any write anywhere in IConfiguration invokes every IOptionsMonitor<T> listener in the app. The .NET team tracked this as dotnet/runtime#109445 and closed it as not planned, so the behaviour is permanent: as long as any part of configuration changes, all IOptionsMonitor instances can trigger their callbacks. If your callback rebuilds an expensive resource, cache the previous value and compare before acting.

OnChange also fires more than once per save. Editors write files in several operations, and the underlying IFileProvider.Watch reports each one, so a single Ctrl+S commonly produces two callbacks and sometimes more. This is dotnet/aspnetcore#2542, and it is a file-watcher artifact, not a bug in the options stack. Make your callback idempotent or debounce it.

File watching is unreliable on Docker volumes and network shares. Set DOTNET_USE_POLLING_FILE_WATCHER=1 to poll instead. The poll interval is four seconds and is not configurable, which is a real constraint if you were counting on faster propagation.

IOptions<T> really does mean forever. The value is bound the first time .Value is read and cached for the process lifetime. If your team’s mental model is “the settings object refreshes”, IOptions<T> will look broken during an incident when a config push does nothing. Decide this per settings class and write it down.

Configuring options with scoped services is a trap regardless of accessor. IConfigureOptions<T> is resolved through the root provider for IOptions<T>, so a scoped dependency injected into your configure delegate becomes a captive dependency. Resolve an IServiceProvider and create a scope inside Configure instead, and remember that scope is not the request’s scope.

What .NET 11 adds

Two things worth knowing, both in the validation layer rather than the accessor layer.

OptionsBuilder<TOptions> gains a generic Validate overload that takes a type parameter instead of a delegate. The type must implement IValidateOptions<TOptions> and be registered in DI, which lines options validation up with the normal DI pattern:

// .NET 11, C# 14
services.AddSingleton<IValidateOptions<MyOptions>, MyOptionsValidator>();
services.AddOptions<MyOptions>()
    .Bind(configuration.GetSection("MyOptions"))
    .Validate<MyOptionsValidator>();

System.ComponentModel.DataAnnotations also learned asynchronous validation in .NET 11, via AsyncValidationAttribute, IAsyncValidatableObject, and Validator.ValidateObjectAsync. Microsoft.Extensions.Options picks that up through a new IAsyncStartupValidator, so an option whose validity depends on a network call can fail the app at startup rather than on first use. Neither change alters which accessor you should inject; both make ValidateOnStart a stronger default than it was in .NET 10.

The recommendation, restated

Start every settings class with IOptions<T>. Move to IOptionsMonitor<T> when a specific singleton has a documented need to observe changes, and dispose the OnChange subscription. Use IOptionsSnapshot<T> only when a scoped consumer needs per-request stability of a value that genuinely changes, and accept that you are paying a full rebind plus revalidation on every request to get it. If you catch yourself reaching for IOptionsSnapshot<T> because a compiler error went away, you have solved a lifetime problem with a performance problem.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back