The right way to handle changing configuration in ASP.NET Core middleware
- 7 minutes read - 1296 wordsYou’ve got a middleware. It reads a comma-separated list of allowed hosts from configuration. Or maybe it loads a dictionary of route mappings, or parses a set of feature flags into a lookup table. The config value can change at runtime and you need to pre-process it into something your middleware can actually use on every request.
How hard can it be? ๐คท
Turns out, this is one of those problems where the obvious solutions are subtly wrong, and the right solution is surprisingly elegant. I went through the ASP.NET Core middleware source code to see how the framework team handles this, and I found a clear winner.
The problem
Let’s say you have a middleware that needs a list of allowed origins, stored in config as a semicolon-separated string:
{
"MyMiddleware": {
"AllowedOrigins": "example.com;contoso.com;fabrikam.com",
"StrictMode": true
}
}
Your middleware needs to:
- Parse that string into a list
- Use the processed list on every request
- React to config changes at runtime (because
reloadOnChange: trueis a thing)
You reach for IOptionsMonitor<T>. Good instinct. But what do you do with the processed result?
The wrong ways (and why)
Attempt 1: process on every request
The simplest approach. Just read CurrentValue and process it in your Invoke method:
public class MyMiddleware
{
private readonly IOptionsMonitor<MyOptions> _options;
public MyMiddleware(RequestDelegate next, IOptionsMonitor<MyOptions> options)
{
_next = next;
_options = options;
}
public Task Invoke(HttpContext context)
{
// Parsing a string on every single request? Ouch.
var allowedOrigins = _options.CurrentValue.AllowedOrigins
.Split(';', StringSplitOptions.RemoveEmptyEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!allowedOrigins.Contains(context.Request.Headers.Origin!))
{
context.Response.StatusCode = 403;
return Task.CompletedTask;
}
return _next(context);
}
}
This works, but you’re paying the cost of splitting strings (each substring is a new string allocation), creating a new HashSet, and doing all that parsing on every single request. For a busy server, that’s a lot of unnecessary garbage for the GC to clean up. If your processing is heavier (loading files, building lookup tables, compiling regexes), it gets worse.
When it’s fine: if your options object is already in a ready-to-use shape and needs no processing, just reading
CurrentValueper request is perfectly acceptable. That’s whatHttpLoggingMiddlewaredoes, and nobody’s complaining.
Attempt 2: cache with a lock
OK, let’s cache the processed result and use a lock to protect updates:
public class MyMiddleware
{
private readonly object _lock = new();
private HashSet<string> _allowedOrigins = new();
private readonly RequestDelegate _next;
public MyMiddleware(RequestDelegate next, IOptionsMonitor<MyOptions> options)
{
_next = next;
UpdateConfiguration(options.CurrentValue);
options.OnChange(UpdateConfiguration);
}
private void UpdateConfiguration(MyOptions options)
{
lock (_lock)
{
_allowedOrigins = options.AllowedOrigins
.Split(';', StringSplitOptions.RemoveEmptyEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
}
public Task Invoke(HttpContext context)
{
HashSet<string> origins;
lock (_lock)
{
origins = _allowedOrigins;
}
if (!origins.Contains(context.Request.Headers.Origin!))
{
context.Response.StatusCode = 403;
return Task.CompletedTask;
}
return _next(context);
}
}
Now every request is fighting for a lock, even though config changes happen once in a blue moon. You’re paying the overhead of lock acquisition on every single request to protect against something that happens almost never. That’s like wearing a helmet to bed just because earthquakes exist.
The FileLoggerProcessor in ASP.NET Core actually uses a lock inside its OnChange callback, but that’s for file I/O operations where atomicity of multiple field updates matters. It’s not on the hot request path.
Attempt 3: Volatile / Interlocked
You might think: “I’ll be clever, I’ll use Volatile.Read or Interlocked to avoid the lock!”
private volatile HashSet<string> _allowedOrigins = new();
This is better than a lock for simple reference swaps (you get the latest reference without locking), but there’s a more fundamental problem: you’re still mutating mutable state across threads, and if you have multiple derived fields that need to stay consistent with each other, volatile alone won’t save you. One thread might see the new _allowedOrigins but the old _strictMode. Now you’ve got a fun race condition that surfaces at 3am.
The right way: the immutable snapshot pattern
Here’s the pattern the ASP.NET Core team uses in the HostFilteringMiddleware. It’s clean, lock-free, and handles multiple derived fields gracefully.
The idea: package all your processed configuration into a single immutable object. When options change, build a new snapshot and swap the reference. Since reference assignment is atomic in .NET, readers always get a fully consistent snapshot without any locking.
Step 1: define your immutable configuration snapshot
// A record is perfect here: immutable by default, value equality, concise syntax.
internal sealed record ProcessedConfiguration(
HashSet<string> AllowedOrigins,
bool StrictMode
);
Step 2: build the snapshot from raw options
internal sealed class ConfigurationManager
{
private ProcessedConfiguration _current;
public ConfigurationManager(IOptionsMonitor<MyOptions> optionsMonitor)
{
_current = Process(optionsMonitor.CurrentValue);
optionsMonitor.OnChange(options => _current = Process(options));
}
public ProcessedConfiguration Current => _current;
private static ProcessedConfiguration Process(MyOptions options)
{
var origins = options.AllowedOrigins
.Split(';', StringSplitOptions.RemoveEmptyEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return new ProcessedConfiguration(origins, options.StrictMode);
}
}
Step 3: use it in your middleware
public class MyMiddleware
{
private readonly RequestDelegate _next;
private readonly ConfigurationManager _configManager;
public MyMiddleware(RequestDelegate next, IOptionsMonitor<MyOptions> options)
{
_next = next;
_configManager = new ConfigurationManager(options);
}
public Task Invoke(HttpContext context)
{
// One read, fully consistent, no locks, no processing.
var config = _configManager.Current;
if (config.StrictMode
&& !config.AllowedOrigins.Contains(context.Request.Headers.Origin!))
{
context.Response.StatusCode = 403;
return Task.CompletedTask;
}
return _next(context);
}
}
That’s it. No locks, no volatile, no per-request allocations, no torn reads.
Why this works
Reference assignment in .NET is atomic for reference types (guaranteed by the ECMA-335 spec, ยงI.12.6.6). When the OnChange callback fires, it builds a brand new ProcessedConfiguration object and assigns the reference in one shot. Any thread reading _current either gets the old snapshot or the new one, never a half-baked mix.
Since the snapshot is immutable (it’s a record with no setters), there’s no risk of one thread reading it while another is modifying it. The old snapshot simply gets garbage collected once no thread references it anymore.
| Approach | Lock-free reads | Consistent multi-field | Reload support | Complexity |
|---|---|---|---|---|
| Process per request | โ | โ | โ | Low, but wasteful |
| Lock | โ | โ | โ | Medium |
| Volatile | โ | โ (single field only) | โ | Medium, error-prone |
| Immutable snapshot | โ | โ | โ | Low |
Where the ASP.NET Core framework uses this
I went through the middleware source code to see who does what. Here’s the breakdown:
Immutable snapshot pattern (the good stuff):
HostFilteringMiddlewareuses a dedicatedMiddlewareConfigurationManagerthat builds an immutableMiddlewareConfigurationrecord on every options change. The middleware just callsGetLatestMiddlewareConfiguration()in its hot path. Zero locks, zero allocations. This is the gold standard.
Read CurrentValue per request (no processing needed):
HttpLoggingMiddlewarereads_options.CurrentValuedirectly inInvoke. Makes sense because the options are already in usable form.W3CLoggingMiddlewaredoes the same.
Pre-process once, no reload (the simple life):
ForwardedHeadersMiddlewaretakesIOptions<T>(notIOptionsMonitor<T>), callsPreProcessHosts()in the constructor, and never looks back. Config changes? Not its problem.- Most other middlewares (StaticFiles, Session, Rewrite, HSTS, ResponseCaching) do the same, taking
IOptions<T>and extracting.Valuein the constructor.
Lock-based OnChange (when you need it):
FileLoggerProcessorusesOnChangewith a lock because it needs to coordinate file I/O state (file numbers, paths, size limits). That’s a valid use of locks, just not on the request hot path.
When to use what
Here’s my rule of thumb:
- Config doesn’t change at runtime? Use
IOptions<T>, read.Valuein the constructor. Done. - Config changes, but needs no processing? Use
IOptionsMonitor<T>, read.CurrentValueper request. - Config changes AND needs processing? Use the immutable snapshot pattern. Build a snapshot in
OnChange, swap the reference. - Config changes AND you’re coordinating side effects (file I/O, external resources)? A lock inside
OnChangeis fine, just keep it off the request path.
Wrapping up
The immutable snapshot pattern is one of those things that feels almost too simple to be worth a blog post. But every time I review code, I see people reaching for locks or volatile or worse, processing config on every request, when a simple reference swap would do.
If it’s good enough for ASP.NET Core’s own middleware, it’s good enough for yours.
Happy coding! ๐
