How to Find Memory Leaks in Production ASP.NET Core Applications?
Before users start complaining about sluggish replies, unsuccessful requests, or unexpected service restarts, an ASP.NET Core application may function properly for hours or days. When developers look into it, they can find that the memory use of the program has been steadily rising.
An examination into memory leaks is frequently prompted by this trend. Rising memory utilization, however, does not always indicate a leak in the program. Applications may purposefully cache data, the.NET garbage collector keeps memory for performance reasons, and traffic surges may momentarily raise allocations.
The challenge is to determine whether memory growth is expected or whether objects are remaining in memory after the application no longer needs them.
This article explains how to investigate memory growth in ASP.NET Core, collect diagnostic data, identify common causes, and verify that a fix works under realistic production conditions.
Understand How Memory Is Managed in .NET
The .NET runtime uses a garbage collector (GC) to reclaim managed objects that are no longer reachable from application code.
For example, when a request creates a temporary object and no references remain after the request completes, the garbage collector can eventually reclaim that object’s memory.
A memory leak occurs when objects remain reachable even though the application no longer needs them. Because the garbage collector sees those objects as reachable, it cannot reclaim them.
Consider a service that stores every incoming request in a static collection. Even if each request finishes successfully, the collection continues holding references to the request data. As more requests arrive, the collection grows and keeps the objects alive.
There are also unmanaged resources, such as certain native allocations or operating-system handles, that may require explicit cleanup. Problems involving those resources can increase process memory or resource consumption even when managed heap measurements appear reasonable.
Distinguish Memory Growth From a Memory Leak
Before changing code, determine which part of the application’s memory usage is increasing.
| Measurement | What it helps explain |
|---|---|
| Managed heap size | Memory occupied by managed objects |
| Allocation rate | How quickly the application creates new objects |
| Garbage collection frequency | How often the runtime performs collection |
| Gen 2 heap behavior | Whether longer-lived objects are accumulating |
| Large Object Heap behavior | Whether large allocations are contributing to growth |
| Process working set | Physical memory currently associated with the process |
| Private bytes | Memory privately committed by the process |
| Handle count | Whether operating-system handles are accumulating |
These measurements describe different aspects of memory usage. They should not be treated as interchangeable.
For example, process working set can remain high after garbage collection because the runtime may retain committed memory for future allocations. A high working set alone does not prove that objects are leaking.
Likewise, a stable managed heap does not rule out unmanaged memory growth.
The first goal is to establish a baseline and identify which measurement changes as the problem develops.
Monitor Memory Before the Application Becomes Unhealthy
The most useful time to begin investigating memory growth is before the application reaches its memory limit.
Monitor the application over a representative period and correlate memory measurements with request volume, deployment events, background jobs, and scheduled tasks.
Look for patterns such as:
- Memory increases steadily even when traffic remains stable.
- Memory rises after each scheduled background operation and never returns to its previous level.
- Gen 2 collections become more frequent while memory continues increasing.
- The application becomes slower as the managed heap grows.
- Process memory increases while managed heap measurements remain relatively stable.
- Memory returns to a lower level after a restart, then follows the same growth pattern again.
A restart temporarily clears many forms of accumulated application state. If memory growth starts again after every restart, that is useful evidence, but it does not identify the cause by itself.
Collect Runtime Metrics With dotnet-counters
The .NET diagnostic tools provide a practical starting point for examining runtime behavior.
Run the following command from an environment where the diagnostic tools can access the target process:
dotnet-counters ps
This lists discoverable .NET processes. Identify the target application’s process and monitor its runtime counters.
dotnet-counters monitor \
--process-id 1234 \
--counters System.Runtime
Replace 1234 with the actual process ID.
On supported .NET versions, the runtime counters include measurements related to allocation rate, GC heap size, garbage collection activity, and other runtime behavior. The exact available counters and their names can vary by runtime version.
Observe the application under normal traffic first, then compare the measurements during a period of increased memory usage.
For a containerized application, ensure the diagnostic tool can access the target process and that the required diagnostic infrastructure is available. Process IDs inside containers may differ from those visible on the host.
Do not install diagnostic tools or expose diagnostic endpoints indiscriminately in production. Use an approved access path and protect diagnostic data because it may reveal information about application behavior.
Capture a Memory Dump When Growth Is Reproducible
Runtime counters can indicate that memory is increasing, but they generally cannot tell you which objects are retaining it.
A memory dump provides a snapshot that can be examined with tools such as dotnet-dump or a compatible memory profiler.
When the process is accessible and the environment supports dump collection, run:
dotnet-dump collect \
--process-id 1234 \
--output memory-growth.dmp
Replace the process ID and output path with the appropriate values.
A dump can contain sensitive application data, including strings, request information, and other in-memory values. Store it securely, restrict access, and follow your organization’s data-handling requirements.
For a long-running application, one dump may not be enough. A single snapshot shows what is retained at one moment but does not establish which objects are accumulating over time.
Where practical, capture comparable dumps at different stages of the growth pattern. Compare object counts, retained memory, and reference paths to identify which object types are increasing.
Inspect the Managed Heap
Open the dump with the appropriate diagnostic tooling and inspect the managed heap.
For example, with dotnet-dump:
dotnet-dump analyze memory-growth.dmp
Inside the analysis session, SOS commands such as the following can help:
dumpheap -stat
This displays a summary of managed heap objects grouped by type.
To inspect a particular object type, use the relevant SOS inspection commands supported by the diagnostic environment. Once you identify suspicious objects, examine their references and determine why they remain reachable.
A useful investigation asks three questions:
- Which object types account for the increasing memory?
- Which objects are keeping those instances alive?
- Which application component should have released or removed those references?
The second question is often the most important. A large object is not necessarily a leak if the application legitimately needs it. A relatively small object can also retain a much larger object graph.
Investigate Static Collections and Unbounded Caches
One common source of memory leaks is application state that grows without a defined limit.
Consider this example:
public static class RequestHistory
{
public static readonly List<string> Requests = new();
}
If every request adds information to this list and the application never removes old entries, the collection continues growing for as long as the process runs.
The static reference keeps the collection reachable, so garbage collection cannot reclaim its contents.
A similar problem can occur in a cache that stores every requested key without expiration or size limits.
Set Explicit Cache Limits
Caching can improve performance, but the application needs a policy for how much data it retains.
ASP.NET Core provides IMemoryCache for in-process caching. Configure expiration and size limits according to the application’s requirements.
For example:
using Microsoft.Extensions.Caching.Memory;
builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = 10_000;
});
When a size limit is configured, each cached entry must specify a size:
public sealed class ProductCache
{
private readonly IMemoryCache _cache;
public ProductCache(IMemoryCache cache)
{
_cache = cache;
}
public void StoreProduct(
string productId,
string productName)
{
var options = new MemoryCacheEntryOptions()
.SetSize(1)
.SetAbsoluteExpiration(
TimeSpan.FromMinutes(15));
_cache.Set(productId, productName, options);
}
}
The size value is application-defined. In this example, each entry counts as one unit, so the limit is an entry-count-style budget rather than a measurement in bytes.
For variable-sized objects, choose a sizing strategy that better represents memory consumption. Also, remember that a cache size limit does not automatically measure or limit the application’s entire memory usage.
If a shared IMemoryCache instance has a size limit, every component adding entries to that instance must supply a size. Otherwise, entries may fail to be added. Test this behavior before enabling a limit in an established application.
For large or distributed caches, consider whether an external cache is more appropriate than retaining all data inside each application process.
Check Event Handlers, Timers, and Long-Lived Services
Objects can remain in memory because a long-lived component holds references to them.
For example, an application may subscribe a request-scoped object to an event published by a singleton service. If the event subscription is never removed, the publisher can keep the subscriber alive after the request finishes.
The same issue can occur with timers, callbacks, background tasks, and collections of delegates.
Review these patterns when heap analysis reveals many objects of a type that should have a short lifetime.
Remove Event Subscriptions When They Are No Longer Needed
A simplified example shows how a subscriber can unsubscribe when its work is complete:
public sealed class NotificationListener : IDisposable
{
private readonly NotificationPublisher _publisher;
public NotificationListener(
NotificationPublisher publisher)
{
_publisher = publisher;
_publisher.NotificationReceived += OnNotificationReceived;
}
private void OnNotificationReceived(
object? sender,
EventArgs args)
{
// Handle the notification.
}
public void Dispose()
{
_publisher.NotificationReceived -=
OnNotificationReceived;
}
}
The important point is that the listener must actually be disposed at the correct lifecycle boundary. Implementing IDisposable alone does not guarantee that cleanup occurs.
In ASP.NET Core, dependency injection can dispose services it creates when their lifetime ends, but manually created objects and subscriptions still need an explicit ownership strategy.
Avoid registering request-specific objects with long-lived event publishers unless the subscription lifetime is carefully managed.
Review Dependency Injection Lifetimes
Incorrect dependency injection lifetimes can keep objects alive longer than intended.
ASP.NET Core commonly uses three service lifetimes:
- Transient: a new instance is created when the service is requested.
- Scoped: one instance is used within a scope, commonly an HTTP request.
- Singleton: one instance is shared for the application’s lifetime.
A singleton that stores references to request-specific objects can prevent those objects from becoming eligible for collection.
A particularly problematic pattern is injecting a scoped service into a singleton and retaining it beyond its intended lifetime. ASP.NET Core’s scope validation can identify some lifetime errors, but not every memory-retention problem is a dependency injection error.
Review singleton fields, static references, background-service state, and long-lived collections when investigating object retention.
If a background service needs scoped dependencies, create a scope for each unit of work and release it when that work finishes.
Investigate Large Allocations and the Large Object Heap
The .NET runtime manages large objects differently from smaller allocations. Large allocations can increase memory pressure and may be expensive when they occur frequently.
Potential causes include:
- Reading entire files into memory when streaming would work.
- Creating large byte arrays for HTTP responses.
- Serializing large object graphs repeatedly.
- Building large strings through repeated transformations.
- Processing many records at once instead of in batches.
For example, loading an entire file with a single read operation may be unnecessary when the application can process it incrementally.
await using var input = File.OpenRead(filePath);
await using var output = File.Create(outputPath);
await input.CopyToAsync(
output,
cancellationToken);
Streaming reduces the need to hold the complete file contents in a single managed buffer. It does not guarantee constant memory usage for every processing pipeline, because downstream code may still buffer data.
Review large object allocations alongside heap statistics and allocation profiles. If the application repeatedly creates large temporary objects, reducing allocation volume may improve performance even when those objects are eventually collected.
Investigate Unmanaged Memory and Native Resources
If process memory grows while the managed heap remains relatively stable, investigate unmanaged allocations and native resources.
Possible causes include:
- Native libraries allocating memory.
- Buffers allocated outside the managed heap.
- Interoperability code that fails to release resources.
- Native handles or operating-system resources accumulating.
- Runtime or library components maintaining native state.
Managed memory tools may not fully explain these cases.
Review the native libraries used by the application and determine whether they require explicit release, disposal, or ownership transfer. For resources represented by managed wrappers, verify that the wrappers are disposed according to their documented lifetime requirements.
Also inspect process-level metrics, handle counts, container memory usage, and relevant operating-system diagnostics. Do not assume that calling GC.Collect() will solve the problem. Forced garbage collection cannot reclaim objects that are still reachable, and it does not replace correct cleanup of unmanaged resources.
Verify the Fix Under Production-Like Load
A memory-related code change should be validated over time rather than judged by a single successful request.
Use a representative workload and observe the application through several cycles of requests, background jobs, and garbage collection.
A useful verification process includes:
- Record baseline heap size, process memory, allocation rate, and traffic.
- Reproduce the growth pattern with a controlled workload.
- Capture diagnostic evidence before and after the suspected growth period.
- Identify the retaining reference or allocation source.
- Apply the fix and repeat the same workload.
- Confirm that memory reaches a stable operating range under comparable conditions.
- Check that latency, error rates, and throughput remain acceptable.
The expected memory profile depends on the application. A service may retain a larger heap after a traffic spike without leaking memory. The important question is whether retained state continues growing beyond what the workload and caching policy justify.
For production services, also verify the effect of the fix on container memory limits, restart frequency, and the application’s ability to recover after traffic decreases.
Common Mistakes to Avoid
Assuming Every Increase Is a Leak
Memory may rise because of legitimate caching, increased traffic, or runtime allocation behavior. Compare heap and process-level metrics before drawing conclusions.
Restarting Without Capturing Evidence
A restart may restore service temporarily, but it can erase the state needed to diagnose the problem. Collect useful metrics and, where appropriate, a dump before restarting, provided doing so is safe for the application.
Calling GC.Collect() Repeatedly
Forced collection does not fix incorrect object retention and can reduce performance. Find and remove the references that keep unwanted objects alive.
Increasing Container Memory Without Investigation
Raising the memory limit may reduce immediate failures, but it can hide an unbounded cache or a growing object graph. Treat capacity changes as a mitigation, not proof that the underlying issue is resolved.
Ignoring Native Allocations
A stable managed heap does not rule out a process-level memory problem. Include native memory and operating-system resource usage in the investigation.
Storing Diagnostic Dumps Without Access Controls
Memory dumps can contain sensitive data. Restrict access, encrypt storage where appropriate, and apply the organization’s retention and deletion requirements.
Advantages and Limitations of Runtime Diagnostics
Runtime counters help identify memory trends with relatively low overhead, making them useful for continuous monitoring and initial triage. Memory dumps provide deeper visibility into object types and reference relationships, which can help identify retention problems that counters cannot explain.
However, dumps can be large, require specialized analysis, and expose sensitive in-memory information. Some memory problems also involve native allocations or external processes that are not fully represented in managed heap analysis.
The most effective approach combines lightweight monitoring, targeted diagnostic captures, source-code review, and repeatable load testing.
Summary
Diagnosing memory leaks in ASP.NET Core begins with distinguishing normal memory behavior from unwanted retention. Monitor runtime counters and process-level measurements, then collect memory dumps when the growth pattern is reproducible.
Inspect static collections, unbounded caches, event subscriptions, dependency injection lifetimes, large allocations, and unmanaged resources. After making a change, repeat the same workload and verify that memory stabilizes without introducing performance or reliability problems.
The goal is not to force the application to use as little memory as possible. It is to understand why memory is being retained, remove unnecessary retention, and keep memory usage predictable as the application handles production traffic.
ASP.NET Core 11.0 Hosting Recommendation
HostForLIFE.eu
HostForLIFE.eu is a popular recommendation that offers various hosting choices. Starting from shared hosting to dedicated servers, you will find options fit for beginners and popular websites. It offers various hosting choices if you want to scale up. Also, you get flexible billing plans where you can choose to purchase a subscription even for one or six months.
