Skip to content

Docs: clarify IOptionsSnapshot<T> performance characteristics #53890

@rosebyte

Description

@rosebyte

Page

Background

This is a doc follow-up to dotnet/runtime#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<T> 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<TOptions>, 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<T> object is constructed.

That is accurate, but it underplays two things:

  1. "Computed once per request" means the entire options pipeline (IConfigureOptions<T>, IConfigureNamedOptions<T>, IPostConfigureOptions<T>, IValidateOptions<T>, including IConfiguration binding when Configure<T>(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 section:

Performance considerations

IOptionsSnapshot<T> recomputes the options instance on every new scope by re-running the full configuration pipeline (IConfigureOptions<T>, IConfigureNamedOptions<T>, IPostConfigureOptions<T>, IValidateOptions<T>, and any IConfiguration binding registered via Configure<T>(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<T> (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<T>. IOptionsMonitor<T> is a singleton that caches the options instance and only rebuilds it when the underlying configuration emits a change notification (via IOptionsChangeTokenSource<T>), 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<IOptionsMonitor<MyOptions>>().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<T> 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions