IOptions, IOptionsSnapshot, IOptionsMonitor: which one sees a config reload

One settings file feeding three labelled tracks: the change reaches IOptionsMonitor at once and IOptionsSnapshot at the start of the next scope, while the IOptions track still shows the old value and is marked as the one that was never going to move
COMPANION REPO · LIVE FROM GITHUB

Three services, one setting, three ways to ask for it. In a constructor they read like three spellings of the same idea.

public sealed class FrozenReader(IOptions<GreetingOptions> options)
{
    public string Read() => options.Value.Greeting;
}

public sealed class ScopedReader(IOptionsSnapshot<GreetingOptions> options)
{
    public string Read() => options.Value.Greeting;
}

public sealed class WatchingReader(IOptionsMonitor<GreetingOptions> options)
{
    public string Read() => options.CurrentValue.Greeting;
}

Nothing in those signatures says which one keeps up when somebody edits appsettings.json under a running process. The answer is in the lifetimes that AddOptions() registers, which is not where most people look:

IOptions<>          Singleton
IOptionsSnapshot<>  Scoped
IOptionsMonitor<>   Singleton

The demo reads those off the ServiceDescriptor list rather than quoting the docs at you, because a registered lifetime is exactly the sort of thing that can change under a package bump without anyone noticing.

From the lifetimes the rest follows. IOptions<T> is a singleton holding a cache of one: it binds the first time something reads .Value and hands back that same object for the life of the process. IOptionsSnapshot<T> is scoped, so every scope gets a fresh accessor with a fresh cache, and the binding happens once per scope. IOptionsMonitor<T> is a singleton as well, but its cache entry is invalidated by the configuration's reload token, so the next read of CurrentValue gives you whatever is current.

One change, three answers

The demo drives the reload with a configuration source whose reload is a method call rather than a file event. Publish sets a key and raises the reload token, which is the same ConfigurationProvider.OnReload the JSON provider calls after re-reading the file. File watchers are timing-dependent, and a flaky test in a repo whose whole point is reproducibility is worse than no test at all, so almost nothing in that suite touches a disk.

Assert.Equal("before", frozen.Read());   // this first read is what binds it

config.Publish("Greeting", "after");

Assert.Equal("after", watching.Read());                     // IOptionsMonitor
using (var scope = provider.CreateScope())
    Assert.Equal("after", scope.ServiceProvider
        .GetRequiredService<ScopedReader>().Read());        // IOptionsSnapshot
Assert.Equal("before", frozen.Read());                      // IOptions

The scope is the interesting one. Open a scope, read the snapshot, publish a change, read it again in that same scope: still the old value, because the cache belongs to the scope and not to the object that asked. In a web app the scope is a request, so "the start of the next scope" means "the next request", which is usually soon enough that nobody notices the delay.

One detail about IOptions<T> that I had wrong until I asserted it. It does not bind at startup. It binds on the first read of .Value, so a change that lands before anything has read it is visible, and every change after that first read is not:

var (provider, config) = Build("before");
config.Publish("Greeting", "after");
config.Publish("Greeting", "later");

var frozen = provider.GetRequiredService<FrozenReader>();
Assert.Equal("later", frozen.Read());       // whatever was current at the first read

config.Publish("Greeting", "too late");
Assert.Equal("later", frozen.Read());       // and that is the last word

In practice something reads it during startup and the distinction never matters. It matters when you are trying to explain why a value is stale, because "bound at startup" sends you looking at the wrong moment.

ONE EDIT · THREE TRACKS · WHERE THE VALUE LANDS
appsettings.json"Greeting": "before" → "after"
IOptionsMonitor<T>SINGLETON
"before"
IOptionsSnapshot<T>SCOPED
next scope starts
"before"
IOptions<T>SINGLETON
bound on the first read
"before"
a singleton that asks for IOptionsSnapshot gets the bottom track's behaviour — loudly in Development, where scope validation is on, and silently everywhere else

The singleton that asks for the scoped one

Here is the part that makes this an architecture question rather than a lookup table. IOptionsSnapshot<T> is scoped, and sooner or later somebody puts it in a singleton, because the constructor gives no hint that it does not belong there.

public sealed class SingletonWantingSnapshot
{
    public SingletonWantingSnapshot(IOptionsSnapshot<GreetingOptions> options)
    {
        CapturedAtConstruction = options.Value.Greeting;
        Snapshot = options;
    }

    public string CapturedAtConstruction { get; }

    // kept so a test can prove that re-reading it later does not help either
    public IOptionsSnapshot<GreetingOptions> Snapshot { get; }

    public string ReadAgain() => Snapshot.Value.Greeting;
}

With ServiceProviderOptions.ValidateScopes on, that resolve throws, and the message is specific enough to fix from:

System.InvalidOperationException: Cannot consume scoped service
'Microsoft.Extensions.Options.IOptionsSnapshot`1[OptionsReloadDemo.GreetingOptions]'
from singleton 'OptionsReloadDemo.SingletonWantingSnapshot'.

With ValidateOnBuild on as well, you get it earlier still: BuildServiceProvider throws an AggregateException wrapping that same text, so the process fails before it has served anything. The demo asserts both, including the message string, so a wording change in a future release shows up as a failing test rather than as a wrong paragraph here.

With validation off, nothing throws. The singleton is constructed once, from the root scope, and the value it read is the value it will report until the process restarts. Two reloads later:

config.Publish("Greeting", "after");

using (var scope = provider.CreateScope())
    Assert.Equal("after", scope.ServiceProvider
        .GetRequiredService<IOptionsSnapshot<GreetingOptions>>().Value.Greeting);

Assert.Equal("before", singleton.CapturedAtConstruction);
Assert.Equal("before", singleton.ReadAgain());     // re-reading does not help

config.Publish("Greeting", "later");
Assert.Equal("before", singleton.ReadAgain());     // nor does the next one

Re-reading does not help because the snapshot object itself came from the root scope, and the root scope never ends. The class holding it is doing everything right by its own lights. It asked for the accessor whose whole job is to track changes, it re-reads it on every call, and it gets the same answer forever.

Which is where the environments come in

ValidateScopes defaults to false on a bare BuildServiceProvider(). The host builders override that, and what they override it to depends on the environment name. Microsoft's DI documentation puts it like this:

When an app runs in the development environment and calls CreateApplicationBuilder to build the host, the default service provider performs checks to verify that: - Scoped services aren't resolved from the root service provider. - Scoped services aren't injected into singletons.

That paragraph names one builder, and there are several. On .NET SDK 10.0.201, against the ASP.NET Core 10.0.5 shared framework (which is where the hosting, options and DI assemblies come from on this machine), the same singleton above fails at Build() under Host.CreateApplicationBuilder, Host.CreateDefaultBuilder, a bare new HostBuilder(), WebApplication.CreateBuilder and WebApplication.CreateSlimBuilder when the environment is Development, and starts cleanly under every one of them when the environment is Production, Staging, or a name nobody has ever heard of. The comparison is OrdinalIgnoreCase, so "development" and "DEVELOPMENT" also turn the checks on; "Development2" does not.

So the mistake is loud on the developer's machine, where DOTNET_ENVIRONMENT or ASPNETCORE_ENVIRONMENT is almost always Development, and silent on the server, where it is almost always not. Loud means the app refuses to start with a message naming both types. Silent means a value that was supposed to reload sits at whatever it was when the singleton was first constructed, which in a web app is usually the first request rather than startup, and the only symptom is that changing the setting does nothing.

If you want the check everywhere, ask for it:

builder.Host.UseDefaultServiceProvider(o =>
{
    o.ValidateScopes = true;
    o.ValidateOnBuild = true;
});

That is the spelling for WebApplication.CreateBuilder, whose Host property is the older host builder. Host.CreateApplicationBuilder has no Host property, so there you want builder.ConfigureContainer(new DefaultServiceProviderFactory(...)) with the same two flags. The demo asserts both spellings.

I would rather fail a deployment on a lifetime mistake than spend an afternoon proving that a config change really was written to the file.

Two ways out

The first is to stop asking for the scoped accessor. A singleton that takes IOptionsMonitor<T> passes validation and reads a current value on every call. That is the one I reach for.

public sealed class WatchingReader(IOptionsMonitor<GreetingOptions> options)
{
    public string Read() => options.CurrentValue.Greeting;
}

The second is to keep the snapshot semantics and open a scope for them, which is the same shape you already use for a DbContext inside a BackgroundService:

private sealed class ScopeOpeningSingleton(IServiceScopeFactory scopeFactory)
{
    public string ReadInAFreshScope()
    {
        using var scope = scopeFactory.CreateScope();
        return scope.ServiceProvider
            .GetRequiredService<IOptionsSnapshot<GreetingOptions>>().Value.Greeting;
    }
}

Both are asserted in the demo under ValidateScopes and ValidateOnBuild, so they are not just descriptions of what ought to work.

Two things that surprised me

IOptionsMonitor<T> only moves on its own if something registered an IOptionsChangeTokenSource<T> for that options type. (Clearing IOptionsMonitorCache<T> by hand also works, and is a different conversation.) Configure<T>(IConfiguration) does that for you. Configure<T>(Action<T>) does not, because a delegate has nothing to watch, so a monitor over delegate-only options is a singleton with a permanent value and a change API that will never fire. Worth knowing before you spend an hour on the file watcher.

And the callbacks are not counted the way you would guess. OnChange registers a listener for change notifications and promises nothing about how many notifications one edit produces. On this machine, Windows 11 with a local NTFS path and the default FileSystemWatcher-backed provider, the first edit after a process starts watching fired the callback twice and every later edit in the same process fired it once. Eight consecutive runs of the demo's file test printed [2, 1, 1] every time; a separate probe that set up twenty watchers in one long-lived process and made four edits against each saw 79 of 80 edits fire once, the single double being the very first. I have not measured this on Linux or macOS, or with DOTNET_USE_POLLING_FILE_WATCHER=1, and the docs already warn that "Some file systems, such as Docker containers and network shares, may not reliably send change notifications". Write the handler so that running it twice for one edit costs nothing, and do not build anything on the number.

There is also a cost to IOptionsSnapshot<T> that has nothing to do with reloads. Because the cache is per scope, the configure delegate runs again for every scope whether or not anything changed. Three scopes, no reload, three runs:

for (var i = 0; i < 3; i++)
{
    using var scope = provider.CreateScope();
    var reader = scope.ServiceProvider.GetRequiredService<ScopedReader>();
    reader.Read();
    reader.Read();          // second read in the same scope is cached
}

Assert.Equal(3, runs);

For a delegate that copies four strings this is nothing. For one that binds a large section, decrypts a secret, or validates with data annotations, it is per-request work you did not know you had, and the monitor does that work once per change instead.

What I do now

IOptionsMonitor<T> in singletons, and in anything that has to notice a change without a restart. IOptionsSnapshot<T> in scoped and transient services where a value that is consistent for the whole request is worth more than a value that is current to the millisecond. IOptions<T> where the setting genuinely cannot change after startup, such as a connection string the connection pool was built from, and where saying so in the constructor is useful documentation.

The version that matters: this is .NET 10 behaviour, measured against SDK 10.0.201 on runtime 10.0.5, with Microsoft.Extensions.Options, Microsoft.Extensions.DependencyInjection and Microsoft.Extensions.Hosting all loaded from the 10.0.5 shared framework. The demo prints those versions as part of its own test run, so you can check them against yours. I have not run the suite against .NET 8 or .NET 9, so treat all of it as a description of .NET 10 rather than a history of the API.

The demo is at github.com/gdhami-net/options-reload-demo. dotnet test runs all 58 tests, or ./check.sh and ./check.ps1 run the same suite and print what each group covers. The reload is a method call in every test but two, so if it goes red on your machine it is telling you something real.