IOptions, IOptionsSnapshot, IOptionsMonitor — and when each one bites
The .NET options pattern ships three interfaces that look interchangeable and are not. When each one reads configuration, what that means for reloads, and the three pitfalls that show up in real codebases.
Every ASP.NET Core codebase binds configuration through the options pattern, and almost every one of them picks between IOptions<T>, IOptionsSnapshot<T> and IOptionsMonitor<T> by habit rather than by intent. The three interfaces look interchangeable — they all hand you a T — but they differ in the one dimension that matters: when the value is computed. Pick wrong and you either serve stale settings forever or tear down a working service at startup.
Let’s bind one options class and walk through what each flavour actually does.
The setup: one class, one section
public sealed class CheckoutOptions{ [Required] public required string PaymentEndpoint { get; init; }
[Range(1, 10)] public int MaxRetries { get; init; } = 3;}builder.Services .AddOptions<CheckoutOptions>() .BindConfiguration("Checkout") .ValidateDataAnnotations() .ValidateOnStart();With that in place, all three interfaces resolve against the same registration. The differences start at the injection site.
IOptions<T>: computed once, cached forever
IOptions<T> is a singleton. The first time anything reads .Value, the framework binds the configuration section, caches the result, and never looks at it again:
public sealed class CheckoutService(IOptions<CheckoutOptions> options){ private readonly CheckoutOptions _options = options.Value; // bound once, process-wide}This is the right default. It’s allocation-free after first access, safe to inject anywhere, and honest about what most settings are: values that change on deploy, not at runtime. If you edit appsettings.json on a running instance, an IOptions<T> consumer will not notice — by design.
IOptionsSnapshot<T>: recomputed per request
IOptionsSnapshot<T> is scoped. Each DI scope — in a web app, each HTTP request — gets a freshly bound instance, then caches it for the duration of that scope:
public sealed class CheckoutEndpoint(IOptionsSnapshot<CheckoutOptions> snapshot){ // re-bound at the start of this request; stable within it public int Retries => snapshot.Value.MaxRetries;}Two things follow from “scoped”. First, config edits show up on the next request without a restart — that’s the feature. Second, you pay the binding cost once per scope, and you cannot inject it into a singleton. More on that below, because that’s the pitfall people actually hit.
IOptionsMonitor<T>: push, not pull
IOptionsMonitor<T> is a singleton that stays current. CurrentValue always reflects the latest configuration, and OnChange gives you a callback when the underlying provider reloads (for JSON files, that’s the reloadOnChange: true file watcher, which is on by default for appsettings.json):
public sealed class PaymentGatewayHealthCheck : IDisposable{ private readonly IDisposable? _subscription;
public PaymentGatewayHealthCheck(IOptionsMonitor<CheckoutOptions> monitor) { _subscription = monitor.OnChange(opts => _endpoint = opts.PaymentEndpoint); }
public void Dispose() => _subscription?.Dispose();}This is the flavour for singletons and background services that must react to runtime changes — feature flags, log levels, circuit-breaker thresholds.
The three pitfalls that actually happen
Snapshot in a singleton
Inject IOptionsSnapshot<T> into a singleton and the container throws at startup (with scope validation on, which is the development default):
System.InvalidOperationException: Cannot consume scoped service 'Microsoft.Extensions.Options.IOptionsSnapshot`1[CheckoutOptions]' from singleton 'CheckoutService'.The fix is not to widen the scope — it’s to admit the consumer is a singleton and use IOptionsMonitor<T>.
Capturing .Value in the constructor
This one compiles, runs, and silently defeats the point:
public sealed class RetryPolicyFactory(IOptionsMonitor<CheckoutOptions> monitor){ private readonly CheckoutOptions _options = monitor.CurrentValue; // frozen at construction private CheckoutOptions Options => monitor.CurrentValue; // reads latest}If you capture the value once, you’ve rebuilt IOptions<T> with extra steps. Keep the monitor, dereference late.
OnChange fires more than once
The file watcher underneath reloadOnChange routinely raises multiple change events for a single save — editors write, truncate and rename in separate operations. If your callback does real work (reconnecting clients, rebuilding caches), debounce it or make it idempotent. Assume “at least once”, never “exactly once”.
Validate at startup, not at first use
ValidateOnStart() is the single most underused line in this whole API. Without it, a missing PaymentEndpoint surfaces as an OptionsValidationException on the first request that touches checkout — in production, at 2 a.m. With it, the host refuses to start, your deploy rolls back, and the broken config never takes traffic. Validation belongs with the people who changed the config, not with the first user who exercises it.
Which one do you actually want?
| You are writing… | Inject | Value reflects |
|---|---|---|
| Almost anything | IOptions<T> |
Startup configuration |
| Request-scoped code that must see edits | IOptionsSnapshot<T> |
Config at request start |
| A singleton/background service that reacts | IOptionsMonitor<T> |
Latest, plus OnChange |
My rule of thumb: start with IOptions<T> everywhere, and treat every upgrade to snapshot or monitor as a design statement — “this value is expected to change at runtime, and this component is expected to care.” If you can’t finish that sentence for the setting in question, you didn’t need the upgrade.