.NET has a garbage collector. That doesn't mean it can't leak — it means when it leaks, the cause is never "forgot to free it." The cause is always "something is still holding on."
Lesson 315 gave you the general framework: what changed, what the graphs show, which dependency is implicated. Now the first specific failure mode this Part applies it to — the one with the slowest, quietest signature of all. Nothing crashes today. Nothing times out today. A dashboard's memory-usage line just keeps climbing, day after day, a little more each time, until eventually the process hits a limit and either the CLR throws an OutOfMemoryException or the orchestrator kills and restarts it for exceeding its memory budget.
The instinct, for anyone who's touched C or C++, is to reach for the word "leak" and picture forgotten malloc calls with no matching free. That mental model is wrong here, and getting it wrong wastes real diagnostic time — .NET's garbage collector (lessons 172, 173) never forgets to free anything it's allowed to free. In this lesson, you'll get the precise, correct definition of a managed memory leak, walk through the handful of patterns that cause almost every real one, and use dotnet-gcdump to actually find one — the same non-invasive-diagnostics discipline lesson 315 introduced, applied to its first concrete target.
A managed memory leak is memory that your application is done using, but that never gets reclaimed — not because the garbage collector forgot to look at it, but because something, somewhere, is still holding a reference to it. As far as the GC can tell, that object is still in active use. It has no way of knowing your intent was to stop using it three minutes ago.
The .NET garbage collector reclaims an object precisely when it becomes unreachable — when no live chain of references, starting from a GC root (a static field, a thread's stack, a still-active handle), leads to it anymore. A managed memory leak is unintended reachability: an object that the application logically no longer needs, but that remains reachable from a root, so the GC — correctly, by its own rules — keeps it alive indefinitely. The GC is not broken and never stops running; it simply cannot collect what your own code is still, unintentionally, pointing at.
malloc, never released with a matching freefree".NET can't leak memory because it has a garbage collector" is a genuinely common and genuinely wrong belief. The GC only collects what's unreachable. It offers zero protection against your own code accidentally keeping something reachable forever — a static List<T> that only ever grows, an event subscription nobody ever unsubscribes, are both perfectly legal, perfectly intentional-looking C#, and both keep memory alive exactly as designed. The bug isn't in the GC. It's in which references your own code chose to hold onto.
Real managed leaks aren't exotic. They come from a short, recurring list of patterns — almost every leak you'll ever chase down in a real .NET codebase belongs to one of these:
this — keeping it alive as long as the callback is.Of these, the event-subscription leak is worth calling out specifically, because it's both the most common in real production code and the least intuitive. When object A subscribes to object B's event (b.SomethingHappened += a.OnSomethingHappened), the reference runs in the direction most people don't expect: B now holds a reference to A, not the other way around. If B outlives A's intended lifetime — B is a singleton service, a static event aggregator, or simply a longer-lived component — A stays alive for as long as B does, purely because it never called -= to unsubscribe. A can be completely unused, completely finished with its work, and it will still never get collected.
Every managed leak, no matter which pattern caused it, has the exact same shape once you trace it: a chain of references leading all the way back to a GC root that never gets broken.
OrderEventBus)OrderProcessor instance that subscribedThis is the exact tool a heap-diffing session gives you: not just "this object is leaking," but the whole chain of "why is it still reachable at all" — which is very often the more useful answer, because the fix is almost always at the far end of that chain (the missing -=), not at the near end (the cache you first noticed was large).
dotnet-gcdump is the diagnostics-suite tool (installed the same way as the tools lesson 315 introduced: dotnet tool install --global dotnet-gcdump) purpose-built for exactly this investigation — it captures a snapshot of the entire managed heap, including every object and every reference between them, without pausing the process for more than the brief moment the snapshot itself takes.
dotnet-gcdump collect -p <pid> — captures the full object graph on the managed heap right now, saved to a .gcdump file.public class PriceFeed
{
// A long-lived singleton, registered once at startup and never destroyed.
public event Action<decimal> PriceChanged;
public void Publish(decimal price) => PriceChanged?.Invoke(price);
}
public class PriceTicker
{
// Created and destroyed constantly — one per UI panel a user opens.
public PriceTicker(PriceFeed feed)
{
feed.PriceChanged += OnPriceChanged; // subscribes, never unsubscribes
}
private void OnPriceChanged(decimal price) => UpdateDisplay(price);
private void UpdateDisplay(decimal price) { /* ... */ }
}
// Every time a user opens and closes a price panel, a new PriceTicker
// is created — and every single one stays alive forever, because
// PriceFeed's event still references OnPriceChanged on all of them.The fix is to implement IDisposable and unsubscribe explicitly, tying the subscription's lifetime to the subscriber's intended lifetime instead of the publisher's:
public class PriceTicker : IDisposable
{
private readonly PriceFeed _feed;
public PriceTicker(PriceFeed feed)
{
_feed = feed;
_feed.PriceChanged += OnPriceChanged;
}
private void OnPriceChanged(decimal price) => UpdateDisplay(price);
private void UpdateDisplay(decimal price) { /* ... */ }
public void Dispose()
{
_feed.PriceChanged -= OnPriceChanged; // breaks the reference explicitly
}
}
// Callers now dispose the ticker when the panel closes — exactly the
// discipline this course has taught for every IDisposable resource —
// and PriceFeed no longer keeps it alive after that point.Meaning: Nothing about this fix involves the garbage collector doing anything differently. The GC always behaved correctly. The fix removes the reference that made the ticker reachable in the first place — once Dispose runs, the next GC that visits PriceTicker finds no live path to it from any root, and reclaims it exactly as designed.
A background worker service — the kind lesson 165 introduced — processes an incoming stream of orders and, for auditing, appends a summary of each one to a static List<OrderAuditRecord> "just in case it's needed for debugging later." The service runs fine in testing, where it only ever processes a handful of orders. In production, after being live for six days, the container's memory usage has quietly climbed from 150 MB to 4 GB, and the orchestrator finally kills it for exceeding its memory limit — right as it happens to be mid-order, taking a customer's in-flight checkout down with it. A dotnet-gcdump taken beforehand (following lesson 315's advice to capture evidence before restarting) would have shown, immediately, that OrderAuditRecord instances outnumbered everything else on the heap by orders of magnitude, all rooted in that one static list — an unbounded collection, exactly the first pattern this lesson named, hiding behind an innocent-sounding comment.
Picture a magazine publisher (the event publisher) that mails an issue to everyone on its subscriber list every month. You subscribed once, years ago, and stopped reading the magazine long ago — but you never called to cancel. The publisher has no way to know you stopped caring; as far as their records show, you're still an active subscriber, so the magazines keep arriving, piling up in your mailbox, forever. The publisher isn't malfunctioning — it's doing exactly what its subscriber list tells it to do. The only way the magazines stop is if you (the subscriber) explicitly call and cancel — the exact real-world equivalent of unsubscribing from an event. A managed memory leak is precisely this: not a broken mail system, but a subscription nobody remembered to end.
Recall from lessons 172 and 173 how the GC actually decides what to collect: starting from the full set of GC roots — static fields, local variables and parameters currently on any thread's stack, and a handful of other runtime-tracked handles — it walks every reachable reference transitively, marking everything it finds as live. Anything left unmarked afterward is garbage, and gets reclaimed. An event's invocation list is, structurally, nothing special — it's just a field on the publisher holding a list of delegate references, each of which holds a reference to its target object. If the publisher itself is reachable from a root (a static field, or a singleton registered in DI and resolved for the app's entire lifetime — lesson 125), then everything in that invocation list is transitively reachable too, and the mark phase finds it, keeps it, and moves on. There's no special-case leniency for "this reference looks unintentional" — the GC only ever answers one question, reachable or not, and it answers it correctly every single time.
dotnet-gcdump works by requesting exactly this graph from the runtime over the same EventPipe channel lesson 315 introduced — a full walk of the heap's object graph and every reference between objects, serialized to a portable file you can analyze afterward without the original process needing to still be running.
A process legitimately using a large, stable amount of memory — a big in-process cache sized deliberately, or the .NET Server GC holding onto reserved-but-currently-unused heap segments for efficiency — is not a leak. A leak is specifically a trend: memory that keeps climbing over time and never comes back down after a full collection. Before chasing a leak, confirm the growth is real and ongoing, not a one-time, intentional, or already-stable memory footprint.
This is worth restating precisely one more time, because it's the single most common wrong assumption walking into a memory investigation: the GC guarantees it will collect anything unreachable — it makes no promise whatsoever about what your own code chooses to keep reachable. A managed leak isn't a failure of garbage collection; it's a success of garbage collection applied to code that unintentionally kept something alive.
A shorter-lived component subscribes to an event on a singleton or static publisher, and never calls -= — the most common single cause of real-world managed leaks.
Implement IDisposable on the subscriber and unsubscribe in Dispose, exactly as this lesson's Simple Example did — tie the subscription's lifetime to the subscriber's, not the publisher's.
Adding to a static Dictionary or List as a quick way to remember things across requests, with no eviction policy and no upper bound.
Use a real caching abstraction (lesson 265) with an eviction policy and a size or time bound — a cache without an eviction strategy is, by definition, a scheduled leak.
A Timer or long-lived callback registered with a lambda that implicitly captures this, keeping the entire enclosing instance alive for as long as the timer runs — even if only one small field was actually needed.
Capture only what's necessary, and dispose the timer or unregister the callback deliberately when the owning object's real lifetime ends.
dotnet-gcdump snapshots, hours apart, and diff them. Whatever object type's count grew the most between the two is your prime suspect — follow its path to root from there rather than guessing at which part of the codebase is responsible.
free — the GC always collects what's genuinely unreachable.free call like in unmanaged code. The GC always collects what's genuinely unreachable; it can't know your intent about anything it's still pointed at.IDisposable objects, and long-lived closures capturing more than intended.dotnet-gcdump snapshots hours apart and diffing them to find the object type that's actually accumulating.You've seen the precise definition of a managed leak, the four repeat-offender patterns, and how to actually find one. Let's confirm it landed.
1. What precisely causes a memory leak in a managed .NET application, given that the runtime has a garbage collector?
Correct: B
Why B is correct: This is the precise, correct framing the lesson opens with — the GC always collects what's unreachable; a leak means something is still, unintentionally, reachable from a root, which is a bug in what your code holds a reference to, not a bug in the collector.
Why A is incorrect: The GC does not "forget" to run — it runs correctly and predictably according to its own reachability rules; the lesson is explicit that the collector itself isn't malfunctioning.
Why C is incorrect: This is the unmanaged C/C++-style leak the lesson explicitly contrasts against — .NET's managed heap doesn't work via malloc/free, and this framing is exactly what the lesson warns against carrying over.
Why D is incorrect: This describes an OS-level memory management issue entirely outside the scope of managed reachability — not what this lesson's leaks are about.
Reinforcement: Managed leaks are about unintended reachability, not a missing deallocation — get this framing right before diagnosing anything.
2. Object A subscribes to an event on object B (b.SomethingHappened += a.OnSomethingHappened). If A is supposed to be short-lived but B is a long-lived singleton, and A never unsubscribes, what happens?
Correct: B
Why B is correct: This is the exact direction of reference the lesson emphasizes — B's event invocation list holds a delegate whose target is A, so B (the publisher) keeps A (the subscriber) reachable, not the other way around. Without an explicit unsubscribe, A lives exactly as long as B does.
Why A is incorrect: Event subscriptions are ordinary delegate references under the hood, and delegate references absolutely affect reachability — this is precisely why the pattern causes real leaks.
Why C is incorrect: The GC has no special-case behavior for "this subscription looks unintentional" — it simply follows reachability, and a still-subscribed delegate keeps its target reachable indefinitely.
Why D is incorrect: This reverses the actual direction — the publisher's event holds references to its subscribers' delegates, not the reverse.
Reinforcement: The reference always runs from publisher to subscriber — a longer-lived publisher can silently keep a shorter-lived subscriber alive forever.
3. A service's memory usage sits at a stable 800 MB for weeks, occasionally dipping to 600 MB after a Gen2 collection, then climbing back to roughly 800 MB again in a repeating pattern. According to this lesson, is this evidence of a memory leak?
Correct: B
Why B is correct: This is exactly the "Common Confusion" point the lesson makes — high or fluctuating memory usage that repeats a stable pattern after collections is ordinary, healthy GC behavior. A leak is specifically defined by a trend: memory that keeps climbing and never comes back down.
Why A is incorrect: Absolute memory usage alone says nothing about a leak — a large but stable working set can be entirely intentional and healthy, as the lesson explicitly notes.
Why C is incorrect: Memory never dropping to zero is completely normal and expected for any running process — the GC doesn't (and shouldn't) reclaim everything down to nothing.
Why D is incorrect: dotnet-trace is the CPU/event-tracing tool from lesson 233/315 — for a memory trend question like this, the relevant tool is dotnet-counters (to observe the trend) or dotnet-gcdump (to diagnose it further), not dotnet-trace.
Reinforcement: Confirm a genuine growth trend before reaching for leak-diagnosis tooling — a stable or repeating pattern isn't a leak.
4. What is the purpose of taking two dotnet-gcdump snapshots hours apart and diffing them, rather than analyzing a single snapshot?
Correct: B
Why B is correct: A single heap snapshot is just a moment in time — it can't tell you what's growing. Diffing two snapshots taken hours apart is exactly what surfaces which object type's count is climbing, pointing you at the real culprit instead of guessing from a static picture.
Why A is incorrect: A single gcdump snapshot is a perfectly valid, analyzable file on its own — it just can't show a trend by itself, which is why the workflow uses two.
Why C is incorrect: Both snapshots matter — the comparison between them, not either one alone, is what reveals the growth.
Why D is incorrect: A gcdump snapshot does capture the full object graph, including references between objects — that's exactly what makes "path to root" analysis possible on either snapshot.
Reinforcement: One snapshot shows what's there; two snapshots, diffed, show what's growing — that difference is the whole point of the workflow.
5. A developer says: "This service allocates a lot of memory per request, so it must have a memory leak." Based on this lesson, what's the issue with that reasoning?
Correct: B
Why B is correct: A high allocation rate per request is normal for many workloads and gets cleaned up fine by the GC as long as nothing stays reachable afterward — that's a throughput/GC-pressure concern, distinct from a leak. A leak specifically requires those objects to remain reachable and never get collected, which allocation volume alone doesn't establish.
Why A is incorrect: This conflates two different concerns — allocating a lot per request (which the GC can handle fine if objects are later unreachable) is not the same as objects staying reachable forever.
Why C is incorrect: This is the exact misconception the lesson corrects — managed code absolutely can leak, precisely through unintended reachability; the lesson exists specifically to explain how.
Why D is incorrect: Managed leaks happen in ordinary, fully-managed C# code all the time — every pattern this lesson covered (event handlers, static collections, undisposed resources, closures) is pure managed code with no unmanaged component required.
Reinforcement: Allocation rate and reachability are different questions — a leak is specifically about the second one.
You now know precisely what a managed memory leak is, the handful of patterns that cause almost every real one, and how to actually find one with dotnet-gcdump. Next up: the opposite kind of resource pressure — a process that's using every ounce of CPU it can get. High CPU.
dotnetmadeeasy.com — Learn C# and .NET, the right way.