ChangeToken.OnChange async reload callbacks can behave differently after recompiling on .NET 11: the lambda may bind to the new task-returning overload, so reloads are serialized and coalesced instead of overlapping as async void. The new behavior is safer, but callback failures can still go unobserved and disposal does not cancel work already in flight.
For most applications, the new binding is safer. It removes async void and coalesces notifications that arrive while a reload is running. The migration still needs an explicit review because asynchronous callback faults are left unobserved by OnChange, disposal does not cancel work already in flight, and coalescing means a callback must reload current state rather than process one delta per notification.
Table of Contents
What changes in .NET 11 ChangeToken.OnChange
Before .NET 11, ChangeToken.OnChange accepted synchronous Action callbacks. Passing an async lambda therefore compiled it as async void. The callback returned to OnChange at its first incomplete await, the next token was registered immediately, and a later notification could start another reload while the first was still running.
// Recompiled against .NET 10: binds to Action and becomes async void.
IDisposable subscription = ChangeToken.OnChange(
configuration.GetReloadToken,
async () =>
{
await ReloadRemoteSettingsAsync();
});
.NET 11 adds overloads that accept Func<Task> and Func<TState, Task>. After the same source is rebuilt against .NET 11, overload resolution selects the Task-returning callback. OnChange waits for that Task to finish before re-registering. Notifications raised while it is running are coalesced into one later callback instead of creating overlapping work.
This is a source-level behavioral change, not a binary break. An existing assembly that is not rebuilt keeps the overload encoded in its IL. There is no AppContext switch because overload selection happens at compile time. That distinction matters in mixed deployments: two services built from the same commit can behave differently if one was rebuilt with .NET 11 and the other only received a newer runtime.
Find callbacks affected by the rebind
Search the solution before the target-framework change. Direct ChangeToken.OnChange calls with an async lambda are the obvious cases, but Task-returning method groups can also bind to the new overload. Include custom configuration providers, plug-in discovery, certificate reloaders, feature-flag refreshers, file watchers, and any library that exposes its own IChangeToken.
rg -n "ChangeToken\.OnChange|OnChange\s*\(" --glob "*.cs" .
Classify each result by behavior rather than syntax. A synchronous callback is unchanged. An async callback that could overlap before now becomes serialized. A callback that relied on handling every individual notification may lose that assumption because bursts are coalesced. A callback that can fault after an await needs local exception handling because the async overload does not observe that fault for the application.
- Record whether overlapping callbacks were possible and whether shared state was protected.
- Determine whether the callback reloads a current snapshot or applies one event-specific delta.
- Identify downstream calls that can time out, fail, or outlive application shutdown.
- Check whether the returned
IDisposableis retained and disposed. - Note any explicit cast to
Action; it deliberately preserves the old fire-and-forget binding.
If the application uses strongly typed options rather than ChangeToken directly, first confirm whether the code belongs at this lower layer. The existing DotNetCoder guide to the ASP.NET Core options pattern explains when IOptionsMonitor<T> is the more natural consumer-facing abstraction. Use direct change-token orchestration only when the reload job itself needs controlled asynchronous work.
Use a production-safe async reload pattern
A safe callback should read the latest complete source, validate it, publish it atomically, and contain its own failures. Keeping the previous valid snapshot is usually safer than replacing live state with a partial or invalid reload. The following coordinator also owns shutdown cancellation and the registration lifetime.
using Microsoft.Extensions.Primitives;
public sealed class SettingsReloadCoordinator : IDisposable
{
private readonly IConfiguration _configuration;
private readonly ISettingsLoader _loader;
private readonly ILogger<SettingsReloadCoordinator> _logger;
private readonly CancellationTokenSource _shutdown = new();
private readonly CancellationToken _shutdownToken;
private readonly IDisposable _subscription;
private SettingsSnapshot _current;
public SettingsReloadCoordinator(
IConfiguration configuration,
ISettingsLoader loader,
ILogger<SettingsReloadCoordinator> logger,
SettingsSnapshot initial)
{
_configuration = configuration;
_loader = loader;
_logger = logger;
_current = initial;
_shutdownToken = _shutdown.Token;
_subscription = ChangeToken.OnChange(
_configuration.GetReloadToken,
ReloadAsync);
}
public SettingsSnapshot Current => Volatile.Read(ref _current);
private async Task ReloadAsync()
{
try
{
SettingsSnapshot next = await _loader.LoadAsync(_shutdownToken);
next.Validate();
Interlocked.Exchange(ref _current, next);
_logger.LogInformation(
"Configuration reload {Revision} applied.",
next.Revision);
}
catch (OperationCanceledException) when (_shutdownToken.IsCancellationRequested)
{
// Normal application shutdown. Keep the last valid snapshot.
}
catch (Exception exception)
{
// Observe the fault here; OnChange leaves async faults unobserved.
_logger.LogError(
exception,
"Configuration reload failed; previous settings remain active.");
}
}
public void Dispose()
{
_shutdown.Cancel();
_subscription.Dispose();
_shutdown.Dispose();
}
}
The callback catches failures after the first await because the .NET 11 API documentation explicitly says those faults are left unobserved. Logging only at a global TaskScheduler.UnobservedTaskException handler is too late and is not a reliable operational strategy. Catch at the reload boundary, emit a bounded error without secret values, and preserve the last-known-good snapshot.
Interlocked.Exchange publishes one completed object instead of mutating a shared settings instance field by field. Readers therefore see either the old valid snapshot or the new valid snapshot. If settings contain disposable resources, replace the simple exchange with an ownership strategy that prevents disposing an object while another request still uses it.
Verify binding, serialization, and failure handling
Do not treat a successful compile as migration proof. Build a focused test around the application’s actual token producer and callback. The test should control completion with a TaskCompletionSource so it can hold the first reload in flight, raise additional changes, and make overlap visible without timing guesses.
- Start one reload and block it before state publication.
- Raise two or more change notifications while the first callback is incomplete.
- Assert that the concurrent-callback count never exceeds one.
- Release the first callback and assert that one follow-up callback reads the latest source state.
- Make the loader fail after an
await; assert the error is logged and the previous snapshot remains active. - Trigger another valid change after the failure; assert the subscription still refreshes successfully.
- Dispose during an in-flight reload; assert shutdown cancellation is handled and no later callback starts.
The expected observation is serialization, not one callback per file-system event. File watchers can already combine, duplicate, or delay notifications, and .NET 11 adds an explicit coalescing boundary while async work is running. Test state convergence: after the burst settles, the active snapshot must match the latest valid source.
Also verify which overload the rebuilt call selects. In a small compile-time guard, assign the callback to a Func<Task> variable before passing it to OnChange. This makes the intended contract visible in review and prevents an accidental cast to Action from restoring async void.
Func<Task> reload = ReloadAsync;
IDisposable subscription = ChangeToken.OnChange(
configuration.GetReloadToken,
reload);
Handle coalescing and shutdown correctly
Coalescing is safe only when the callback is level-triggered: it asks, “What is the current complete configuration?” An event-triggered callback that assumes notification one represents delta one and notification two represents delta two can miss intermediate transitions. Move durable events to a queue or log with offsets; use ChangeToken to invalidate and rebuild current state.
Disposing the subscription prevents future registrations, but it does not automatically cancel user code already running. The callback has no framework-supplied CancellationToken, so the owner must provide one if I/O should stop during shutdown. Cancel first, dispose the registration, and let the callback recognize that cancellation as an expected shutdown path.
A slow or stuck reload now delays re-registration. Give outbound calls explicit timeouts, report reload duration, and alert on the age of the active snapshot. Avoid adding an internal retry loop that can run forever; bounded retries may be reasonable for transient I/O, but a later change notification should still have a chance to refresh state.
- Do not log configuration payloads, secrets, connection strings, or tokens on failure.
- Validate cross-field invariants before swapping the snapshot.
- Expose the applied revision and last successful reload time as health data.
- Use a readiness failure only when stale settings make the service unsafe; otherwise keep serving with the last valid state.
- Document whether a failed reload requires an operator action or will be retried on the next signal.
Roll out the .NET 11 change safely
Review and test affected callbacks before changing the target framework in the main branch. Then deploy one instance or environment with metrics for reload starts, successful applications, failures, duration, coalesced follow-up work, active revision age, and shutdown cancellation. Compare those signals with the previous build instead of assuming fewer callbacks always mean lost work.
If production code truly requires the old fire-and-forget behavior, an explicit Action cast preserves it. That choice also restores async void, overlapping callbacks, and harder exception handling, so treat it as a temporary compatibility measure with a named removal condition. There is no runtime switch that can make this decision after deployment.
The safer migration is usually to accept the .NET 11 Task-returning binding and strengthen the callback around it: read current state, validate before publishing, observe faults locally, own cancellation, and test convergence after a burst. Those controls turn a silent overload change into an explicit reload contract that remains understandable under failure and shutdown.
References
- Microsoft Learn: ChangeToken.OnChange async overloads rebind existing callbacks
- Microsoft API reference: ChangeToken.OnChange
- dotnet/runtime #129624: Add async ChangeToken.OnChange overloads and tests
- dotnet/runtime #130492: Use the async overload in FileConfigurationProvider
- .NET 11 Preview 7 library release notes
Found this useful? Support more practical developer content.