Skip to main content
C# Memory Management Gotchas

C# Memory Management: Expert Insights to Avoid Hidden Leaks and Performance Traps

Memory management in C# can feel like a solved problem: the garbage collector (GC) handles allocation and deallocation, so developers rarely think about free. Yet production incidents tell a different story—services that steadily consume more memory, response times that degrade over hours, and mysterious OutOfMemoryExceptions that only appear under load. The truth is that the GC is not a memory leak prevention system; it reclaims memory only for objects that are no longer reachable from application roots. If your code accidentally keeps references alive, the GC cannot help. This article is for .NET developers who have seen memory grow without explanation and want to understand the hidden traps that cause leaks and performance degradation. We will focus on real-world gotchas—event handlers, static collections, finalizers, string interning, and more—and give you concrete strategies to prevent them.

Memory management in C# can feel like a solved problem: the garbage collector (GC) handles allocation and deallocation, so developers rarely think about free. Yet production incidents tell a different story—services that steadily consume more memory, response times that degrade over hours, and mysterious OutOfMemoryExceptions that only appear under load. The truth is that the GC is not a memory leak prevention system; it reclaims memory only for objects that are no longer reachable from application roots. If your code accidentally keeps references alive, the GC cannot help. This article is for .NET developers who have seen memory grow without explanation and want to understand the hidden traps that cause leaks and performance degradation. We will focus on real-world gotchas—event handlers, static collections, finalizers, string interning, and more—and give you concrete strategies to prevent them.

Why Memory Leaks Still Happen in Managed Code

Many teams assume that because C# runs on a garbage-collected runtime, memory leaks are impossible. That assumption is dangerous. A memory leak in .NET occurs when an object remains reachable from a GC root (static field, local variable on a thread stack, or a GC handle) but is no longer needed by the application. The GC will never collect it, and the memory is effectively lost. Common roots include static collections, event handlers, and captured variables in lambdas. The problem is subtle because the object graph can be large and indirect: a single rooted object can keep an entire tree alive.

Consider a typical scenario: a service that caches data in a static Dictionary<string, Customer>. Over time, the cache grows without bound because entries are never removed. The developer might think the cache is harmless because the GC will clean up unused items, but the dictionary itself is a root, and every entry is reachable. This is not a bug in the GC—it is a design flaw. The same pattern appears with event handlers: subscribing to a long-lived object's event from a short-lived object prevents the subscriber from being collected. The event publisher holds a reference to the subscriber's handler delegate, which in turn holds a reference to the subscriber object. Until the handler is unsubscribed, the subscriber stays alive.

The performance impact goes beyond memory pressure. A bloated heap forces the GC to work harder during collections, increasing pause times and CPU usage. In server applications, this can lead to timeouts and degraded throughput. Understanding these mechanisms is the first step to writing robust, leak-free code.

The Role of GC Roots

GC roots are the entry points that the garbage collector uses to traverse the object graph. They include static fields, thread-local storage, local variables on active stack frames, and GC handles (like those used by GCHandle or P/Invoke). Any object reachable from a root is considered alive. If you accidentally keep a reference in a static field, that object and everything it references will never be collected. This is why static collections are a common source of leaks—they are roots that live for the entire application domain.

Event Handlers and the Publisher-Subscriber Trap

Events are a classic leak vector. When object A subscribes to an event on object B (the publisher), B stores a reference to A's handler delegate. The delegate captures a reference to A (the target). If B lives longer than A, A will not be collected until it unsubscribes. This is especially problematic in UI applications where controls subscribe to static or long-lived services. The fix is to use weak event patterns, explicitly unsubscribe, or use WeakReference for the subscriber.

Core Mechanisms: How the GC Decides What to Collect

To avoid leaks, you need to understand how the GC determines reachability. The .NET GC uses a mark-and-compact algorithm (with generational collection). It starts from the roots and marks every object it can reach. Unmarked objects are considered garbage. Objects are promoted through generations (0, 1, 2) based on survival. Most objects die young, so generation 0 collections are fast. But objects that survive into generation 2 are scanned rarely, which means a leak can persist for a long time before you notice.

The key insight is that the GC does not know about your application's semantics. It only knows about references. If you have a cache that holds references to objects that are logically expired, the GC treats them as live. This is why manual cleanup or weak references are necessary. Another important mechanism is the finalizer queue. Objects with finalizers are not collected immediately; they are placed on a queue and finalized on a dedicated thread. This delays memory reclamation and can cause objects to survive an extra collection, promoting them to an older generation. Overuse of finalizers is a performance gotcha that many developers overlook.

Generations and Survival

New objects are allocated in generation 0. If they survive a collection, they move to generation 1, and then to generation 2. The GC collects generation 0 frequently, generation 1 less often, and generation 2 rarely. A leak that keeps objects in generation 2 means they will not be collected until a full blocking collection occurs, which can be expensive. This is why you should avoid promoting objects unnecessarily—for example, by holding references to them in long-lived collections.

Large Object Heap (LOH) Fragmentation

Objects larger than 85,000 bytes go to the Large Object Heap, which is not compacted by default (in .NET Framework; .NET Core+ can compact it on request). This can lead to fragmentation: even if you free objects, the memory may not be reusable for new allocations of the same size, causing OutOfMemoryException even when total free space is sufficient. The gotcha is that temporary large buffers can fragment the LOH. Solutions include pooling (using ArrayPool<T>), reducing object size, or forcing compaction in .NET Core with GCSettings.LargeObjectHeapCompactionMode.

Under the Hood: References, Pinning, and the GC's Blind Spots

To write leak-free code, you must understand how references work at the runtime level. The GC tracks object references, but it cannot see references held by native code or by GCHandle structures that are not properly freed. Pinning—used when passing managed objects to native code—prevents the GC from moving an object, which can cause heap fragmentation. If you pin an object and forget to unpin it, that object becomes immovable and can prevent compaction of the surrounding memory.

Another blind spot is WeakReference. A weak reference does not prevent collection, but it can be resurrected if the target is still alive. The gotcha is that WeakReference targets can be collected at any time, so you must check the IsAlive property and handle the case where the target is gone. Misusing weak references—for example, assuming they will always be alive—can lead to null reference exceptions or stale data.

String Interning and Memory Permanence

Strings are a special case. The runtime maintains an intern pool that stores one instance of each unique literal string. When you call string.Intern, the string is added to the pool and will never be collected for the lifetime of the application domain. This can be a memory leak if you intern dynamically generated strings (like from a web request). The pool grows without bound. Avoid interning strings that are not literal constants. If you need to deduplicate strings, consider a custom ConcurrentDictionary with weak references or a bounded cache.

Thread-Local Storage (TLS) and AsyncLocal

Data stored in thread-local storage (via ThreadStatic or ThreadLocal<T>) is rooted as long as the thread is alive. In thread-pool environments, threads are reused, so TLS data can persist across operations, causing leaks or incorrect state. AsyncLocal<T> flows with the execution context, which can also hold references longer than expected. The gotcha is that if you store large objects in TLS and forget to clear them, they remain reachable until the thread dies. Always clean up TLS in a finally block or use IDisposable patterns.

Worked Example: Diagnosing a Real-World Leak

Let's walk through a composite scenario that combines several gotchas. Imagine a web API that processes orders. It uses a static ConcurrentDictionary to cache order lookup results. Each cached item holds a reference to an Order object, which in turn holds a reference to a Customer object. The Customer object subscribes to a static EventBus for notifications. Additionally, the cache uses string.Intern on order IDs to save memory. Over time, memory grows continuously.

The first issue is the static dictionary: it never removes entries. The second is the event subscription: each Customer object is kept alive by the EventBus, even after the order is processed. The third is string interning: order IDs are interned, so every unique ID ever seen stays in the intern pool. The result is a leak that eventually exhausts memory.

To fix this, we would: (1) implement an eviction policy for the cache (e.g., MemoryCache with expiration), (2) use weak event pattern or unsubscribe in Dispose, and (3) remove the string.Intern call—use a regular string comparison instead. After these changes, memory usage stabilizes. This example shows how multiple small leaks compound into a major problem.

Using a Memory Profiler

The best way to find leaks is to use a memory profiler like dotMemory, PerfView, or the Visual Studio Diagnostic Tools. Take a snapshot, perform an action, take another snapshot, and compare. Look for types that grew unexpectedly. Trace their reference chains back to roots. In our example, you would see Order objects held by the dictionary, and Customer objects held by event handlers. The profiler makes the invisible visible.

Common Fix Patterns

For static collections, use ConditionalWeakTable if you need to associate data with objects without preventing collection. For events, consider weak event managers (like WeakEventManager in WPF or a custom implementation using WeakReference). For finalizers, implement IDisposable properly and suppress finalization if you dispose deterministically. For LOH fragmentation, use ArrayPool or reduce allocation size.

Edge Cases and Exceptions

Not all memory growth is a leak. Sometimes it is expected behavior, like a cache that is sized correctly. But there are edge cases that confuse even experienced developers. One is the Timer class: a System.Threading.Timer keeps a reference to its callback delegate, which can prevent the timer object from being collected if you lose the reference. Always dispose timers explicitly. Another is the Task continuation: if you have a task that never completes, continuations may hold references indefinitely. Use cancellation tokens and timeouts.

Another edge case is the DynamicMethod and assemblies created via System.Reflection.Emit. These assemblies are loaded into the current application domain and cannot be unloaded (in .NET Framework; .NET Core has collectible assemblies). If you generate dynamic methods repeatedly, you leak memory. Use collectible assemblies (AssemblyBuilderAccess.RunAndCollect) or reuse generated delegates.

Finalizer Resurrection

A finalizer can resurrect an object by making it reachable again (e.g., by storing a reference in a static field). This is almost always a bug. The GC will not collect resurrected objects until the next collection, and they may survive multiple collections. Avoid resurrecting objects in finalizers. If you must, use GC.ReRegisterForFinalize sparingly and with extreme caution.

MarshalByRefObject and Remoting

In legacy remoting scenarios, MarshalByRefObject objects have a lease that can prevent collection. If the lease is not managed correctly, objects can stay alive longer than expected. Modern .NET applications rarely use remoting, but if you encounter it, be aware of lease management.

Limits of the Approach: When GC Tuning Isn't Enough

Even with perfect code, the GC has limitations. It is a tracing collector, which means it pauses the application during collections (though concurrent and background modes reduce pauses). For real-time or low-latency systems, GC pauses can be unacceptable. In those cases, you might need to use GC.TryStartNoGCRegion to suppress collections during critical sections, or move to unmanaged memory via Marshal.AllocHGlobal and manual management. However, manual memory management introduces its own risks (dangling pointers, double frees).

Another limit is that the GC cannot handle memory pressure from native resources. If you allocate large native buffers (e.g., via P/Invoke), the GC does not know about them unless you use GC.AddMemoryPressure. Without that, the GC may not trigger collections in time, leading to OutOfMemoryException from the system. Always report native allocations to the GC.

Finally, the GC's behavior varies between runtimes (.NET Framework vs .NET Core vs Mono). For example, .NET Core has better support for LOH compaction and server GC modes. Test your application on the target runtime and measure memory under realistic load. Do not assume that what works on your dev machine will work in production.

When to Consider a Different Approach

If your application has strict memory budgets (e.g., game consoles, embedded systems), you might need to avoid the GC entirely by using Span<T> and stack allocation, or by writing performance-critical parts in C++ with C# interop. For most business applications, though, the GC is adequate if you follow best practices.

Next Steps: Practical Actions

1. Audit your static collections: add eviction policies or use ConditionalWeakTable.
2. Review event subscriptions: ensure subscribers unsubscribe or use weak events.
3. Check for string.Intern usage on non-literal strings and remove it.
4. Implement IDisposable correctly in classes that hold native resources or event handlers.
5. Use a memory profiler to take snapshots before and after a typical operation; look for unexpected type growth.
6. Monitor LOH fragmentation with performance counters like ".NET CLR Memory".
7. If you use ThreadLocal or AsyncLocal, ensure cleanup in finally blocks.
8. Consider using MemoryCache or IMemoryCache with expiration and size limits instead of custom dictionaries.
9. Test with server GC mode for high-throughput services.
10. Document your memory management assumptions so future maintainers don't reintroduce leaks.

Share this article:

Comments (0)

No comments yet. Be the first to comment!