A recent investigation by Microsoft engineers has spotlighted a significant memory leak issue affecting .NET applications on Windows, particularly after upgrading to .NET 7 or newer.
The root cause? Improper use of the reloadOnChange parameter in the Microsoft.Extensions.Configuration APIs—a seemingly innocuous setting that can quietly cripple application performance and stability over time.
The reloadOnChange parameter, when set to true, instructs the application to monitor configuration files (such as appsettings.json) for changes and reload them dynamically.
While this feature enables real-time configuration updates without restarting the application, its misuse—especially when invoked repeatedly in application code—leads to a gradual and persistent increase in memory usage.
This can ultimately result in OutOfMemoryException errors and severe performance degradation.
Technical Deep Dive: How the Leak Manifests
The problem arises from how .NET handles file monitoring on Windows. When reloadOnChange is enabled and IConfigurationBuilder.Build() is called, the framework internally creates a FileSystemWatcher monitor to monitor the specified file.
Each invocation allocates an 8KB System.Byte[] buffer, which is pinned in memory for use by the Windows API function ReadDirectoryChangesW:
csharpIConfiguration configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.Build();
var someConfig = configuration["someConfig"];
The signature of the underlying Windows API call is:
cBOOL ReadDirectoryChangesW(
HANDLE hDirectory,
LPVOID lpBuffer,
DWORD nBufferLength,
BOOL bWatchSubtree,
DWORD dwNotifyFilter,
LPDWORD lpBytesReturned,
LPOVERLAPPED lpOverlapped,
LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
The buffer passed as lpBuffer must be pinned, preventing the garbage collector from relocating it.
Over time, as more such pinned buffers accumulate, especially if the configuration builder is invoked in frequently executed code paths like controller actions or middleware, memory fragmentation increases.
This leads to large portions of the managed heap (particularly Generation 2) becoming unusable, as pinned objects block memory compaction and reuse.
Diagnostic commands such as !gcheapstat and !dumpheap in WinDbg, or equivalent commands in dotnet-dump, reveal a growing number of System.IO.FileSystemWatcher+AsyncReadState objects and pinned System.Byte[] arrays.
These objects persist in memory, causing heap fragmentation and a steady rise in committed memory, with much of it left as unusable free space.
Best Practices and Remediation Steps
Microsoft engineers emphasize that reloadOnChange: true should only be used during application startup, ideally once, to set up configuration monitoring.
Most ASP.NET Core apps already load and monitor standard configuration files automatically, making additional manual monitoring unnecessary. Repeatedly calling Build() with reloadOnChange: true In runtime, code is a common mistake that triggers the leak.
To prevent or resolve this issue:
- Restrict Use of
reloadOnChange: Only enable it during application startup, not in runtime logic or per-request code. - Leverage Dependency Injection: Access configuration data via dependency injection rather than rebuilding configuration objects repeatedly.
- Set
reloadOnChange: falsefor Dynamic Loads: If dynamic loading is unavoidable, ensurereloadOnChangeis set tofalseto avoid unnecessary file monitoring and buffer pinning. - Monitor Memory Usage: Use tools like
dotnet-counters,dotnet-dump, and Visual Studio diagnostics to detect early signs of memory leaks and fragmentation.
By adhering to these practices, developers can avoid subtle but devastating memory leaks that undermine .NET application reliability on Windows.
This issue underscores the importance of understanding the technical implications of framework features and the need for vigilance when updating application dependencies or coding patterns.
Find this Story Interesting! Follow us on LinkedIn and X to Get More Instant updates