Memory leaks in C# are often dismissed because of the garbage collector. But any .NET developer who has watched a production process grow to 2 GB and then crash knows the collector is not a silver bullet. The problem is not that the GC fails—it is that we keep references alive without realizing it. This article walks through eight subtle patterns that cause memory to accumulate, explains the underlying mechanism, and gives concrete fixes. Whether you are maintaining a legacy WinForms app or building a modern ASP.NET Core service, these leaks can degrade performance and cause unexpected outages.
1. Event Handlers That Outlive Their Subscribers
Event handlers are the most common source of accidental references. When object A subscribes to an event on object B, B holds a strong reference to A. As long as B is alive, A cannot be collected—even if the application has no other reference to A. This is especially dangerous in UI frameworks where long-lived objects (like a main window or a singleton service) expose events, and short-lived views subscribe to them.
Consider a WPF application where a main window raises a 'SettingsChanged' event, and each child dialog subscribes to it. When the user closes a dialog, the dialog goes out of scope, but the main window still holds a reference to the dialog's handler method. The dialog's entire visual tree stays in memory, including any bound data. Over hours of usage, the application accumulates dozens of dead dialogs.
How to detect and fix
Use the 'Memory Allocation' view in PerfView or dotMemory to see which objects have unexpected roots. A common fix is to unsubscribe in the subscriber's Dispose method or to use weak event patterns (WeakEventManager in WPF, or the 'WeakReference' approach). For long-lived publishers, consider using a 'WeakEvent' implementation that does not prevent the subscriber from being collected.
Another approach is to invert the dependency: instead of subscribing to a global event, pass a callback interface or use a message bus that does not hold strong references. The reactive extensions (Rx.NET) can also help, but be careful with subscriptions there too—they require explicit disposal.
2. Static Collections That Grow Unbounded
Static fields are roots for the garbage collector. Any object reachable from a static variable will never be collected. A static dictionary used as a cache, a list that stores all created instances for logging, or a static event that accumulates handlers—all of these can grow without bound if entries are never removed.
A typical scenario is a static 'ConcurrentDictionary' used to cache expensive computations. The developer adds entries but never implements an eviction policy. Over weeks of runtime, the cache consumes gigabytes. Even worse, if the cached values themselves hold references to large objects (like data tables or images), the memory pressure multiplies.
Eviction strategies that work
The simplest fix is to use 'MemoryCache' (from System.Runtime.Caching) or 'IMemoryCache' in ASP.NET Core. These provide expiration policies (absolute time, sliding window, size limits). If you must use a dictionary, wrap it with a 'Timer' that periodically removes stale entries, or use a 'WeakReference' dictionary for disposable data. Another pattern is to limit the collection to a fixed capacity and implement LRU (least recently used) eviction manually—but be aware that manual LRU can introduce locking overhead.
Always ask: does this static collection have a removal path? If not, it is a leak waiting to happen. In team code reviews, flag any static collection that does not have a documented eviction policy.
3. Lambda Closures Capturing Large Objects
Lambdas and anonymous methods capture variables from the enclosing scope. The compiler generates a closure class that holds strong references to all captured variables. If a lambda is long-lived (e.g., stored as a callback or event handler), the captured objects stay alive too. Developers often forget that a lambda captures the entire variable—not just its value at the time of capture.
For example, a method that processes a large byte array and registers a continuation: 'Task.Run(() => Process(data));'. The lambda captures 'data', and if the Task is long-running, the array remains in memory even after the processing completes. This is especially problematic in loops where each iteration captures a different variable—the compiler creates a new closure for each iteration, and if the delegate is stored, all closures survive.
Minimizing closure footprint
One technique is to copy only the needed fields into local variables before the lambda, but that still captures those locals. A better approach is to pass data explicitly as parameters to a method group instead of a lambda, or to use a 'WeakReference' if the data can be re-created. For short-lived delegates, the leak is temporary, but for delegates that are stored in static collections or long-lived events, the memory accumulates. Use 'dotMemory' to inspect closure objects: they appear as generated classes like '<>c__DisplayClass0_0'.
In performance-critical paths, consider refactoring to a state machine that does not rely on closures, or use 'ValueTask' and 'ValueTuple' to reduce allocations. While closures are convenient, they are not free—measure their impact before assuming they are harmless.
4. Finalizer Suppression Mistakes
When a class implements 'IDisposable' and also has a finalizer, the common pattern is to call 'GC.SuppressFinalize(this)' in 'Dispose()' to avoid redundant finalization. However, if 'SuppressFinalize' is called too early—before the object is truly done with unmanaged resources—the finalizer never runs, and resources leak. Conversely, if 'SuppressFinalize' is omitted when it should be called, the object survives to the finalization queue, delaying collection and increasing memory pressure.
A more subtle issue: objects that derive from a base class that calls 'SuppressFinalize' in its 'Dispose' but do not override 'Dispose' themselves. The derived class may hold unmanaged resources, but because the base class suppressed finalization, those resources are never released. This is common in custom stream wrappers or database connection wrappers.
Safe disposal pattern
Follow the standard Dispose pattern rigorously: have a protected 'Dispose(bool disposing)' method, and only call 'SuppressFinalize' after all resources (both managed and unmanaged) are released. Use 'SafeHandle' or 'SafeBuffer' for unmanaged resources when possible—these classes handle finalization correctly. In code reviews, verify that every 'Dispose' path calls 'SuppressFinalize' exactly once, and that derived classes do not skip cleanup.
Testing for finalizer leaks can be done by forcing a GC and checking if the object's finalizer is called (e.g., using a 'bool finalized' flag in a test). If the flag is never set, suppression may be happening too early.
5. Thread-Safe Caches Without Eviction
Concurrent collections like 'ConcurrentDictionary' are popular for caches because they handle multi-threaded access. But thread safety does not prevent memory leaks. A common pattern is a cache that stores results per user or per request, with no mechanism to remove old entries. Over time, the cache grows to contain every unique key ever seen.
For example, an ASP.NET Core application that caches user preferences in a 'ConcurrentDictionary' keyed by user ID. As users accumulate, the dictionary holds every preference object forever. Even if the user becomes inactive, the cache holds the data. This is especially problematic in applications with millions of users—the cache can quickly exceed available memory.
Designing a self-cleaning cache
Use 'MemoryCache' with a sliding expiration of, say, 20 minutes. This ensures that inactive users' data is evicted. If you must use 'ConcurrentDictionary', implement a background timer that removes entries last accessed before a threshold. Another option is to use 'WeakReference' as values, so the GC can reclaim them if no other strong references exist—but this only works if the values are not shared elsewhere.
Consider also the key space: if keys are strings, they are interned and stay in memory. Use a key that can be garbage-collected (e.g., a short-lived object) or limit the total number of keys. In microservices, a distributed cache (Redis) may be more appropriate than an in-memory cache, because it moves memory pressure out of the process.
6. String Concatenation in Loops
While string immutability is a well-known concept, the memory implications of concatenation in loops are still underestimated. Each concatenation creates a new string, and the old string becomes garbage. In a loop with thousands of iterations, the temporary strings accumulate in Gen 0 and 1, causing frequent collections and high allocation rates. But the real leak happens when the concatenated result is stored in a static or long-lived field—then all intermediate strings are reachable through the final string's internal character array.
Consider a logging framework that accumulates log messages by concatenating them: 'log += entry + Environment.NewLine;'. Over a long-running process, the log string grows to megabytes, and every intermediate string is still referenced by the final string's construction history (due to string interning and the way StringBuilder works internally? Actually, no—the intermediate strings are not referenced once the next concatenation occurs, but the final string holds a large character array. The leak here is not the intermediate strings, but the unbounded growth of the final string. If the log is never truncated, it consumes ever more memory.
Use StringBuilder or streaming
Always use 'StringBuilder' for repeated concatenation, or better, write to a stream or a file incrementally. If you must keep a log in memory, set a maximum size and truncate old entries. For high-throughput logging, use a structured logging library (Serilog, NLog) that writes to a sink asynchronously and does not accumulate strings in memory.
Another hidden cost: string interpolation in hot paths creates 'FormattableString' objects that can also cause allocations. Precompile format strings or use 'string.Create' for performance-critical scenarios. Measure allocation rates with a profiler before optimizing—but as a rule, avoid concatenation in loops that run more than a few hundred iterations.
7. Improperly Disposed Unmanaged Resources
Unmanaged resources (file handles, network sockets, GDI objects, database connections) must be explicitly released. The 'using' statement is the standard pattern, but it is easy to forget, especially when resources are stored in fields or passed across methods. A common leak is holding a 'SqlConnection' open because the 'using' block was omitted in an exception path, or because the connection is stored in a field and disposed only when the owning object is finalized—which may never happen if the object is reachable.
Another pattern: creating a 'Graphics' object from a bitmap and not disposing it. GDI handles are limited per process; once the limit is reached, drawing operations fail with 'OutOfMemoryException' even though managed memory is fine. The GC does not reclaim GDI handles promptly because finalization is not guaranteed to run before the limit is hit.
Safe resource management
Wrap every unmanaged resource in a 'using' block or a 'try/finally' that calls 'Dispose'. For resources stored in fields, implement 'IDisposable' and dispose them in the 'Dispose' method. Use 'SafeHandle' wrappers to ensure release even if 'Dispose' is not called. In ASP.NET Core, the DI container can manage disposal of scoped services—but only if the service is registered as scoped and resolved correctly. Avoid storing 'HttpClient' in a static field without disposal; use 'IHttpClientFactory' instead.
Use diagnostic tools like 'Handle Leak' detection in PerfView or the 'Windows Performance Recorder' to track handle counts. If you see handles increasing over time, look for missing 'Dispose' calls. Code analysis rules (CA2000, CA2202) can catch some issues at compile time.
8. Deadlock-Prone Locks That Prevent Cleanup
Memory leaks are not always about references—they can also be about threads that are blocked indefinitely. When a thread holds a lock and never releases it (due to a deadlock or an exception that bypasses 'finally'), any resources that thread was about to dispose may never be released. Worse, if the lock protects a cache or a collection, the entire data structure becomes inaccessible but remains in memory.
Consider a 'ReaderWriterLockSlim' used to protect a static cache. If one thread acquires a write lock and then throws an exception before releasing it, the lock state becomes corrupted. Subsequent threads wait forever, and the cache (and all its entries) is never cleaned up because no thread can enter the critical section. The application appears to hang, and memory grows because the cache cannot be trimmed.
Lock patterns that avoid leaks
Always use 'lock' statements with a dedicated synchronization object, and avoid 'lock(this)' or 'lock(typeof(MyClass))' to prevent external interference. For 'ReaderWriterLockSlim', use 'try/finally' to ensure release. Better yet, use 'SemaphoreSlim' with 'await' and 'try/finally' in async code. Consider using 'ConcurrentDictionary' with atomic operations instead of custom locking for simple caches.
If a deadlock is suspected, take a memory dump and analyze thread stacks with WinDbg or dotMemory. Look for threads waiting on 'Monitor.Enter' or 'ReaderWriterLockSlim' with no progress. The fix is often to reduce lock granularity or use a lock-free data structure. Also, ensure that all lock acquisitions have a timeout or a cancellation token, so that blocked threads can be detected and recovered.
Finally, consider using 'AsyncLocal' or 'ImmutableCollections' to avoid locks altogether in read-heavy scenarios. The less locking you do, the fewer opportunities for deadlocks that silently prevent cleanup.
Memory leaks in C# are rarely about the GC failing—they are about us holding references longer than needed. By auditing event subscriptions, static collections, closures, finalization, caches, string usage, resource disposal, and lock safety, you can eliminate the most common hidden leaks. Start with one pattern this week: pick the one that matches your current project and apply the fix. Your production servers will thank you.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!