Docs: clarify IOptionsSnapshot<T> performance characteristics
- Dominant language
- No language data
- Stars
- 4.8k
- Forks
- 6.1k
- Avg merge
- 15h 21m
- Merged PRs (30d)
- 370
Description
## Page
- https://learn.microsoft.com/dotnet/core/extensions/options
- API reference for `IOptionsSnapshot`: https://learn.microsoft.com/dotnet/api/microsoft.extensions.options.ioptionssnapshot-1
## Background
This is a doc follow-up to [dotnet/runtime#53793](https://github.com/dotnet/runtime/issues/53793), which was closed as by-design. Several commenters on that issue asked for the design choice to be made more visible in the conceptual docs, because the perf characteristics of `IOptionsSnapshot` are surprising in practice (in particular for ASP.NET Core apps, where a scope is typically a single HTTP request).
## What the docs currently say
> When you use `IOptionsSnapshot`, options are computed once per request when accessed and are cached for the lifetime of the request.
> `IOptionsSnapshot` is a scoped service and provides a snapshot of the options at the time the `IOptionsSnapshot` object is constructed.
That is accurate, but it underplays two things:
1. "Computed once per request" means the entire options pipeline (`IConfigureOptions`, `IConfigureNamedOptions`, `IPostConfigureOptions`, `IValidateOptions`, including `IConfiguration` binding when `Configure(configSection)` is used) runs again **for every named instance that is resolved in every new scope**, not just when the underlying configuration actually changes.
2. In an ASP.NET Core app the scope lifetime is effectively the request, so the recomputation cost is paid on the request hot path. For options classes with non-trivial binding (large/nested config, validation, post-configure logic) this is measurable, can be tens to hundreds of microseconds per request per named instance, and is essentially invisible from the API surface.
## Proposed changes
Add a section after the [Use IOptionsSnapshot to read updated data](https://learn.microsoft.com/dotnet/core/extensions/options#use-ioptionssnapshot-to-read-updated-data) section:
> **Performance considerations**
>
> `IOptionsSnapshot` recomputes the options instance on every new scope by re-running the full configuration pipeline (`IConfigureOptions`, `IConfigureNamedOptions`, `IPostConfigureOptions`, `IValidateOptions`, and any `IConfiguration` binding registered via `Configure(section)`). It does this whether or not the underlying configuration has actually changed, and it does it once per named instance that is resolved in that scope.
>
> In ASP.NET Core, a scope corresponds to a single HTTP request, so this cost is paid on the request hot path. For options whose binding or post-configuration is non-trivial (large or deeply nested configuration sections, expensive `PostConfigure`, validation, etc.), this can be a meaningful per-request overhead.
>
> If you do not actually need the value to potentially change after the app has started, prefer `IOptions` (singleton, value computed once for the lifetime of the app).
>
> If you do need to observe configuration changes but the cost of rebuilding the options on every scope is too high and avoidable (the pipeline isn't specific to each scope), prefer `IOptionsMonitor`. `IOptionsMonitor` is a singleton that caches the options instance and only rebuilds it when the underlying configuration emits a change notification (via `IOptionsChangeTokenSource`), so steady-state reads of `CurrentValue` / `Get(name)` are O(1) cache hits. If you also need the snapshot semantics that IOptionsSnapshot provides (a value that is guaranteed not to change for the duration of a scope), you can layer them on top of IOptionsMonitor in one of two ways:
> - Capture monitor.CurrentValue once at the start of the scope into an ambient per-scope context, for example HttpContext in ASP.NET Core, and read it back from there for the rest of the scope.
> - Register the options type itself as scoped with a factory that pulls the current value from the monitor, for example:
`sp.GetRequiredService>().CurrentValue);`.
## Why this matters
The current wording leads readers (and a lot of SO answers / blog posts) to focus on the "you get a consistent value within a scope" aspect and to treat `IOptionsSnapshot` as the natural default for reloadable options. The fact that it is also the most expensive of the three interfaces on a per-request basis is not obvious from either the conceptual docs or the API ref, and the runtime team has confirmed the cost is by design and won't be changed. Documenting it explicitly is the most useful thing we can do for users.
## References
- Original perf issue: https://github.com/dotnet/runtime/issues/53793
- `IOptionsSnapshot`: https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Options/src/IOptionsSnapshot.cs
- `OptionsManager` (the per-scope cache + factory used to satisfy `IOptionsSnapshot`): https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Options/src/OptionsManager.cs
Contributor guide
Assessment
This issue has not been assessed yet.