"But .NET has a garbage collector" is the sentence right before every .NET developer's first production memory leak. This guide is for experienced engineers who own long-running services and need to find why a process's memory keeps climbing until it is killed and restarted. You will learn what a leak actually means when a collector is doing its job correctly, the causes that account for nearly every real-world case, how unmanaged memory leaks differ from managed ones, a repeatable investigation workflow using dotnet-gcdump and dotnet-dump, how to read a GC root chain with SOS, and how to prevent leaks from reaching production in the first place.
What Counts as a Memory Leak in a Garbage-Collected Runtime?#
The .NET garbage collector never frees an object that is still reachable from a root: a local variable on an active stack, a static field, a GC handle, or the finalization queue. That is not a bug, it is the whole point of tracing garbage collection. A managed memory leak is therefore not the GC failing to do its job; it is your code accidentally keeping a reference alive longer than you intended, so the collector correctly, faithfully, keeps the object around too. Garbage Collection in .NET covers generations, roots and collection mechanics in depth; this guide assumes that background and focuses specifically on tracking down why something is still rooted when it should not be.
Not every memory increase is a leak. A cache that grows to a steady size and then stops, a connection pool that expands under load and contracts afterward, and a larger generation 2 after a burst of long-lived allocations are all normal. The signature of a real leak is a floor that keeps rising: even right after a full, blocking garbage collection, the smallest amount of memory the process holds onto keeps increasing run after run, under steady, repeated load.
How Objects Stay Alive: Roots and Reachability#
Three patterns account for almost every accidental root chain. A static field, including a static readonly collection used as an ad hoc cache, lives for the entire process and roots everything reachable from it. An event subscription creates a strong reference from the publisher's invocation list to the subscriber's target, so a long-lived publisher keeps every subscriber alive until it explicitly unsubscribes. A captured closure stored somewhere long-lived, such as a delegate cached in a dictionary or registered as a callback, keeps alive everything the lambda captured, including this and any field reachable from it, even when the capture looks incidental. Every cause in this guide is one of these three patterns wearing a different disguise.
Getting Started: Confirming You Actually Have a Leak#
Before chasing code, confirm the symptom with dotnet-counters against steady, repeatable load, ideally in a staging environment where you can force the traffic pattern:
dotnet-counters monitor --process-id 4821 --counters System.Runtime --refresh-interval 5Watch gc-heap-size (or the .NET 9+ equivalent metric) immediately after each full, generation 2 collection, not the peak between collections. If that post-collection floor is flat across an hour of steady load, you likely have normal cache warm-up or pooling, not a leak. If it climbs collection after collection, move on to the investigation workflow below. Also compare the managed heap size against the process's working set with working-set: a managed heap that stays flat while the working set keeps growing points at an unmanaged leak instead, covered later in this guide.
Common Cause: Event Handlers and Unsubscribed Observers#
This is the classic "lapsed listener" problem. A view model, a UI element or a request-scoped service subscribes to an event on something longer-lived, such as a static event, a cache's change notification, or a singleton message bus, and never unsubscribes:
public sealed class PriceTicker
{
// Long-lived: registered once at startup
public event Action<decimal>? PriceChanged;
public void Publish(decimal price) => PriceChanged?.Invoke(price);
}
public sealed class PriceWidget : IDisposable
{
private readonly PriceTicker _ticker;
public PriceWidget(PriceTicker ticker)
{
_ticker = ticker;
_ticker.PriceChanged += OnPriceChanged; // ticker now holds a reference to this widget
}
private void OnPriceChanged(decimal price) { /* update UI */ }
public void Dispose() => _ticker.PriceChanged -= OnPriceChanged; // required, not optional
}Every PriceWidget that is created and discarded without calling Dispose stays alive for as long as PriceTicker does, because the ticker's invocation list holds a strong delegate reference to it. The fix is always to unsubscribe, whether that happens in Dispose, in a matching lifecycle event, or by using a weak event pattern (a broker that stores WeakReference<T> to subscribers) when the subscriber's lifetime genuinely cannot be tied to a deterministic unsubscribe call.
Common Cause: Static Fields, Caches and Timers#
A static field lives for the process, so anything reachable from it is never collected while the process runs. The most common offender is a hand-rolled cache with no eviction:
// Grows forever: nothing ever removes an entry or bounds the dictionary's size
private static readonly Dictionary<string, ReportResult> s_reportCache = new();
public static ReportResult GetReport(string key) =>
s_reportCache.TryGetValue(key, out var cached) ? cached : s_reportCache[key] = BuildReport(key);System.Threading.Timer and System.Timers.Timer are a related trap: a running timer keeps its callback, and anything the callback closes over, alive until the timer is disposed, even if nothing else references the timer anymore. A timer created per request, per user session or per connection, and never disposed on the corresponding teardown path, accumulates one live callback chain per timer forever.
Common Cause: Closures Capturing More Than You Think#
A lambda captures variables, not values, and captures this implicitly the moment it touches an instance member. When that lambda is stored somewhere long-lived, every captured reference is kept alive too:
public sealed class OrderProcessor(IMessageBus bus, ILogger<OrderProcessor> logger)
{
public void StartWatching(Order order)
{
// Captures 'this' (for 'logger') and 'order'; if 'bus' subscribers are never removed,
// both OrderProcessor and every watched Order are kept alive indefinitely.
bus.Subscribe<OrderCancelled>(msg =>
{
if (msg.OrderId == order.Id)
{
logger.LogInformation("Order {OrderId} cancelled", order.Id);
}
});
}
}The same pattern shows up when a scoped or transient service is captured by a delegate that a singleton holds, such as a lambda passed to IMemoryCache.GetOrCreate with a factory that closes over a per-request DbContext, or an event handler registered on application startup that captures a request- scoped object. Prefer passing state explicitly as a method parameter, use static lambdas so the compiler flags accidental captures as a compile error, and always provide a way to remove a subscription that a closure was registered under.
Common Cause: IDisposable Misuse and HttpClient#
Forgetting to dispose an IDisposable does not usually leak the wrapper object itself, the GC still collects it, but it can leak whatever unmanaged resource the object wraps: file handles, database connections not returned to a pool, or native buffers behind a SafeHandle. Without a deterministic Dispose, that cleanup either never happens or happens late, on a finalizer thread, which also delays collection of the object itself by at least one extra generation.
HttpClient deserves its own callout because its failure mode looks like a leak but is really socket exhaustion. Each HttpClient owns its own connection pool through its handler, and disposing a client does not immediately release the underlying TCP connections, which can sit in TIME_WAIT. Creating and disposing a new HttpClient per request exhausts available client-side ports under load:
// Wrong: a new connection pool per request, and ports pile up in TIME_WAIT under load
using var client = new HttpClient();
var response = await client.GetAsync(uri, ct);
// Right: IHttpClientFactory owns pooling and rotates handlers on DNS change
public sealed class PricingClient(HttpClient httpClient)
{
public Task<PriceQuote> GetQuoteAsync(string sku, CancellationToken ct) =>
httpClient.GetFromJsonAsync<PriceQuote>($"/quotes/{sku}", ct)!;
}
// Program.cs
builder.Services.AddHttpClient<PricingClient>(c => c.BaseAddress = new Uri("https://pricing.internal"));IHttpClientFactory-managed clients share and rotate pooled handlers safely, which is why it is the recommended approach for essentially all outbound HTTP calls in modern .NET.
Unbounded Caches and Collections#
Any collection that only grows, a per-user session dictionary, a ConcurrentDictionary used as an ad hoc cache, or a list appended to on every request and never trimmed, is a slow-motion leak even though every entry is individually legitimate. Bound every in-memory cache explicitly:
builder.Services.AddMemoryCache(options => options.SizeLimit = 10_000);
cache.Set(key, value, new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = TimeSpan.FromMinutes(10),
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2),
});When a cache's keys should not themselves be kept alive by the cache, ConditionalWeakTable<TKey, TValue> associates data with an object without extending its lifetime, which fits metadata that should disappear the moment its owning object does. Caching in .NET covers sizing, eviction and distributed caching in more depth.
Unmanaged Memory Leaks#
Not every leak lives on the managed heap. Native memory allocated through P/Invoke (Marshal.AllocHGlobal, NativeMemory.Alloc), unmanaged handles wrapped by a SafeHandle whose owning object is never disposed, and leaks inside third-party native libraries you call into, none of these show up in a dotnet-gcdump, because that tool only reconstructs the managed object graph. The tell is a process working set that keeps growing while dotnet-counters' managed heap size counters stay flat: at that point, reach for a platform-native tool, such as VMMap or a UMDH baseline comparison on Windows, or a tool like Valgrind's memcheck, or simply comparing /proc/<pid>/smaps snapshots over time, on Linux. Always dispose types that wrap unmanaged resources through using, and make sure any SafeHandle-derived type is actually released on every code path, including exceptions.
Step-by-Step Investigation Workflow#
- Confirm the trend with
dotnet-countersunder steady, repeatable load, watching the post-collection heap floor rather than the peak, as shown above. - Rule out unmanaged memory by comparing the managed heap size counter against the process working set; a growing gap between them points outside the GC entirely.
- Capture two snapshots minutes apart, under the same steady load, with
dotnet-gcdump:
bash dotnet-gcdump collect --process-id 4821 -o snapshot1.gcdump # ...wait several minutes under steady load... dotnet-gcdump collect --process-id 4821 -o snapshot2.gcdump
- Diff the snapshots in PerfView or Visual Studio's managed memory analyzer. The object types with the largest growth in count and retained size between the two snapshots are your leak candidates, not necessarily the types with the largest absolute count.
- Take a full dump when you need the exact reference chain, since a dump lets you run
gcrootinteractively against a specific object address, which a gcdump viewer's diff view does not always expose as clearly:
bash dotnet-dump collect --process-id 4821 --type Heap -o leak.dmp dotnet-dump analyze leak.dmp
- Fix the root cause the chain points to, then repeat steps 1 and 3 to confirm the floor stops rising under the same load pattern you used to reproduce it.
Finding GC Roots with SOS#
Inside dotnet-dump analyze (or WinDbg with the sos extension loaded), dumpheap -stat gives an object-type histogram of the whole managed heap, ordered by total bytes, which is where you confirm your leak candidate from the gcdump diff:
> dumpheap -stat
MT Count TotalSize Class Name
00007ffab3c12345 48213 7,714,080 MyApp.Domain.OrderWatcher
...
> dumpheap -mt 00007ffab3c12345
Address MT Size
00007f2a1c003010 00007ffab3c12345 160
> gcroot 00007f2a1c003010
Thread 12:
00007f2a0a1ffb20 (stack reference)
-> 00007f2a1b8002a0 MyApp.Messaging.InMemoryBus
-> 00007f2a1b800340 System.Collections.Generic.List`1[Action`1[OrderCancelled]]
-> 00007f2a1c003010 MyApp.Domain.OrderWatcherRead a gcroot chain from the top down: it starts at an actual root, here a stack reference, and ends at your target object. Every link in between is the field or collection entry that keeps the next object alive, so the second-to-last line almost always names the exact subscription, cache or static field you need to fix, in this case an event subscription list on a long-lived message bus. When more than one chain is printed, or your tool supports it, run gcroot -all to see every path keeping the object alive; fixing only the first chain found can leave the object rooted by a second, unrelated path.
Best Practices: Preventing Leaks#
- Pair every subscribe with an unsubscribe on a deterministic lifecycle event, and prefer weak event patterns when the subscriber's lifetime cannot be tied to one.
- Bound every cache explicitly, with a size limit and an expiration policy, never an unbounded dictionary used as an ad hoc cache.
- Dispose every
IDisposablewithusingorawait using, and enable analyzers such as CA2000 that flag objects that are created but never disposed on some code path. - Use
IHttpClientFactoryinstead of manually constructing and disposingHttpClientinstances. - Watch what a singleton captures. Injecting a scoped service, or a closure that captures one, into a singleton is one of the most common ways a short-lived object ends up rooted for the life of the process.
- Add production monitoring for the trend, not just the value. A dashboard or
dotnet-monitorcollection rule that alerts on a rising post-GC heap floor catches a leak in hours instead of after an out-of-memory restart days later. The .NET Diagnostics Toolkit covers setting this up. - Run soak tests. A multi-hour test at steady, realistic load in CI or staging, with periodic
dotnet-counterssnapshots, catches slow leaks before they reach production traffic.
Common Pitfalls#
- Confusing normal steady-state growth with a leak. A cache that grows once to its configured limit and then stops is working correctly; only a floor that keeps rising across collections is a leak.
- Trusting a single snapshot. One
dotnet-gcdumpor dump shows what is alive right now, not what is growing; you need at least two, under comparable load, to see a trend. - Chasing the type with the highest count instead of the highest growth. A framework type with millions of short-lived instances is often irrelevant; the type whose count keeps climbing between snapshots is the one that matters.
- Fixing only the first
gcrootchain found when multiple independent chains keep the same object alive, then being surprised the leak persists. - Reproducing under unrealistic load, such as a single user hammering one endpoint, which can hide leaks that depend on request diversity, such as per-tenant or per-user cache keys that never repeat.
- Assuming
GC.Collect()"proves" a leak because memory does not drop afterward. That is exactly the correct signal; calling it repeatedly in production code, however, does not fix anything and adds expensive full collections.
Tool Comparison for Memory Leak Diagnosis#
| Tool | Shows | Overhead | Best for |
|---|---|---|---|
dotnet-counters | Heap size trend over time | Near zero | Confirming a leak exists before deeper analysis |
dotnet-gcdump | Managed object graph snapshot | Low, brief pause | Diffing two points in time to find growing types |
dotnet-dump + SOS | Full heap and native state, interactive queries | Higher, longer pause | Finding the exact gcroot chain for a specific object |
| PerfView | GC heap diffs, allocation stacks | Moderate | Visual diffing and attributing allocations to call stacks |
dotnet-monitor | Automated, rule-triggered collection | Near zero when idle | Unattended capture in production without manual access |
The .NET Diagnostics Toolkit covers installing and using each of these tools in detail, including how to collect them safely from containers and Kubernetes pods.
Frequently Asked Questions#
Does the .NET garbage collector prevent memory leaks?#
No, and it is not designed to. The GC reliably reclaims everything that is unreachable; a leak happens when your code keeps something reachable, through a static field, an unremoved event subscription or a captured closure, longer than intended. The GC is doing exactly what it promises even while a leak grows.
How can I tell a real leak from normal cache growth?#
Watch the heap size immediately after a full, generation 2 collection over a sustained period of steady, repeated load. Legitimate caches and pools grow to a size and then hold steady; a real leak's post- collection floor keeps rising indefinitely, collection after collection, with no ceiling.
Do I need to worry about memory leaks from small IDisposable objects?#
Yes, if they wrap an unmanaged resource. The managed wrapper object is small and the GC reclaims it fine, but if Dispose never ran, the file handle, native buffer or pooled connection it owned may not be released, which can exhaust an operating system resource long before it shows up as managed memory growth.
Can a finalizer fix a memory leak?#
A finalizer is a safety net for releasing unmanaged resources if Dispose was never called, not a fix for a rooting problem. An object with only a managed leak, one that is simply still reachable, is never even finalized, because finalization only matters once nothing references the object anymore.
Why does creating a new HttpClient per request look like a leak?#
It usually is not a managed memory leak; it is client-side port and connection exhaustion, since each HttpClient instance owns its own pool and disposing it does not instantly release sockets held in TIME_WAIT. Symptoms include growing thread and handle counts and connection failures under load. Switch to IHttpClientFactory and the problem generally disappears without any other change.
How often should I capture gcdump snapshots in production?#
For an active investigation, minutes apart is usually enough to see a clear trend under steady load. For ongoing monitoring, prefer counters and alerting over routine gcdump collection, and reserve on-demand gcdumps, ideally triggered automatically by a dotnet-monitor collection rule, for when the trend already looks abnormal.
Summary#
- A managed leak means an object is unintentionally still reachable from a root; the GC is behaving correctly even as the leak grows.
- Event subscriptions, static caches and timers, captured closures, undisposed resources and unbounded collections account for nearly every real-world managed leak.
- HttpClient misuse and unmanaged P/Invoke allocations produce leak-like symptoms outside the managed heap entirely.
- Confirm the trend with
dotnet-counters, isolate the growing type with twodotnet-gcdumpsnapshots, then find the exact reference chain withdotnet-dumpandgcroot. - Prevent leaks with disciplined subscribe and dispose lifecycles, bounded caches,
IHttpClientFactory, and production monitoring that watches the trend, not just the current value.