You write C# code that runs fine in small tests. Then, under production load, memory grows, GC pauses spike, and the process eventually dies with an OutOfMemoryException. The root cause is rarely a single bug—it's a combination of patterns that the garbage collector cannot clean up efficiently. This guide names six specific gotchas we see repeatedly in real projects and shows how to patch each one. We assume you know the basics of GC generations and finalization; we focus on the traps that slip past code review.
1. Who This Affects and What Goes Wrong Without These Fixes
Every team that builds server-side .NET applications—ASP.NET Core APIs, background services, desktop tools with long uptime—encounters these issues eventually. The cost is not just memory: high allocation rates trigger frequent Gen2 collections, which block threads and increase latency. In one composite scenario, a team saw request latency double over 48 hours because a forgotten event subscription caused a large object graph to remain rooted. Another team noticed their Windows service grew to 2 GB after a week, only to discover a static dictionary that accumulated cache entries with no eviction policy. Without patching these gotchas, you risk unplanned restarts, degraded performance, and hard-to-diagnose production incidents. The fixes are not complex, but they require understanding how the GC views object references and finalization queues.
The first step is recognizing that memory leaks in .NET are almost always logical leaks—objects that are still referenced but no longer needed. The GC cannot collect them because the application holds a strong reference. This is different from unmanaged memory leaks (e.g., P/Invoke handles), but the symptom is the same: growing private bytes. We will cover both managed and unmanaged patterns, because many teams overlook the unmanaged side until it's too late.
After reading this guide, you will be able to audit your codebase for the six most common patterns, apply targeted fixes, and set up monitoring to catch regressions before they reach production. We also provide a quick checklist in the final section so you can run through it during code reviews.
2. Prerequisites: What You Should Have in Place Before Diving In
Before you start patching memory gotchas, make sure you have a baseline. You need a memory profiler—either a commercial tool like JetBrains dotMemory, or the free PerfView from Microsoft. Without profiling, you are guessing. Install the tool and take a snapshot of your application under a realistic workload. Note the total managed heap size, number of Gen2 collections, and largest object types. This baseline will let you measure improvement after each fix.
You also need a clear understanding of your application's object lifecycle. Which objects live for the entire process? Which are scoped to a request or a background job? If you cannot answer these questions, start by reviewing your dependency injection configuration. Transient, scoped, and singleton lifetimes directly affect memory pressure. A common mistake is registering a service as singleton when it captures a scoped dependency—this creates a captive dependency that can keep large object graphs alive.
Finally, ensure you have a staging environment that mirrors production memory and CPU constraints. Testing on a developer workstation with 32 GB of RAM hides leaks that manifest under memory pressure. Use a container with memory limits (e.g., Docker with --memory=512m) to simulate constrained conditions. This will make Gen2 promotions more frequent and expose leaks faster.
If you are working with an existing legacy codebase, also check whether you have any third-party libraries that use finalizers or subscribe to static events. Some libraries inadvertently hold references to your objects. We will discuss how to detect these in the debugging section.
3. Core Workflow: Six Gotchas and How to Patch Each One
We present the gotchas in order of frequency. Each includes a short explanation, a code snippet showing the problematic pattern, and the fix.
3.1 Event Handlers That Outlive Subscribers
When you subscribe to an event on a long-lived object (e.g., a static event or a singleton), the subscriber cannot be collected until it unsubscribes. This is the most common managed leak. The fix: always unsubscribe, or use weak event patterns. For example, if you attach a handler in a view model that is meant to be short-lived, ensure the view model implements IDisposable and removes the handler in Dispose(). In WPF or XAML applications, the WeakEvent pattern is built into the framework; for custom events, consider using WeakReference or the WeakEventManager from Microsoft.
// Problem: static event keeps 'subscriber' alive
public static event EventHandler SomethingHappened;
// Solution: unsubscribe in Dispose()
public class Subscriber : IDisposable
{
public Subscriber()
{
SomethingHappened += OnSomethingHappened;
}
public void Dispose()
{
SomethingHappened -= OnSomethingHappened;
}
}
3.2 Captured Variables in Lambdas and Closures
Lambdas that capture local variables extend the lifetime of those variables. If the lambda is passed to a long-lived delegate (e.g., a timer callback or a Task continuation), the captured objects stay alive. The fix: minimize the capture scope, or avoid capturing large objects. In performance-critical paths, consider using a static method or a pool of reusable delegates.
3.3 Finalization Queue Buildup from Unfinalized Objects
Objects with finalizers that are not disposed properly move to the finalization queue and survive a Gen0 collection, promoting them to Gen1. If the finalizer thread is blocked or slow, the queue grows and memory pressure increases. The fix: implement the Dispose pattern correctly, and call Dispose() on all IDisposable objects. Use a using statement or declare the object in a using declaration. If you must have a finalizer, suppress it in Dispose().
3.4 Large Object Heap Fragmentation
Objects larger than 85,000 bytes go to the Large Object Heap (LOH), which is not compacted by default. Over time, allocations and deallocations create free-space gaps that cannot be reused for new large objects, leading to OutOfMemoryException even when total free memory is high. The fix: avoid frequent allocations of large temporary arrays. Consider using ArrayPool<T> to rent and return buffers. For long-lived large objects, allocate them once and reuse.
3.5 String Interning and Concatenation Loops
Strings are immutable, so concatenating in a loop creates many temporary strings that pressure Gen0. Worse, interning strings (via string.Intern or literal reuse) pins them in memory for the process lifetime. The fix: use StringBuilder for loops, and avoid interning unless you have a proven memory benefit. For high-throughput logging, use a pooled StringBuilder or the newer string.Create method.
3.6 Unmanaged Memory Not Released
P/Invoke calls, COM interop, or calls to native libraries allocate unmanaged memory that the GC does not track. If you do not release these handles, memory grows without bound. The fix: wrap unmanaged resources in SafeHandle or a class implementing IDisposable with a finalizer. Use the 'using' pattern. For GDI+ or file handles, always dispose promptly.
4. Tools, Setup, and Environment Realities
You cannot fix what you cannot measure. Start with PerfView (free, from Microsoft) to collect GC stats and heap snapshots. Run your application under load and capture a .gcdump file. Open it in PerfView or Visual Studio to see the object graph. Look for unexpected roots: static variables, event handlers, and ThreadLocal<T> values. dotMemory provides a more visual interface and can show reference chains from a selected object to the GC root.
For real-time monitoring, set up performance counters: .NET CLR Memory\# Bytes in all Heaps, .NET CLR Memory\Gen 2 Collections, and Process\Private Bytes. Alert when Gen2 collections exceed 5 per minute or when private bytes grow linearly over hours. In .NET Core / .NET 5+, use the EventCounters API to publish these metrics to Application Insights or Prometheus.
Environment matters: a 32-bit process has a 2–3 GB address space limit; a 64-bit process can grow until it exhausts system memory. If your application runs in a container with memory limits, the GC is more aggressive, and fragmentation becomes visible sooner. Test under these constraints. Also, be aware that the GC mode (Workstation vs. Server) affects heap layout. Server GC creates one heap per core, which can reduce contention but increase memory usage. Choose based on your workload pattern.
5. Variations for Different Constraints
Not every application needs the same approach. For a desktop tool with short uptime, you might tolerate some leaks because the process exits regularly. For a 24/7 service, every leak matters. Here are three common scenarios and how to adjust.
5.1 High-Throughput Web API
In ASP.NET Core, the request pipeline creates many short-lived objects. The biggest gotcha here is captive dependencies in DI—when a singleton service holds a reference to a scoped service, the scoped service lives forever. Use the factory pattern or register the service as scoped and inject it via a scope factory. Also, avoid large allocations per request; use ArrayPool<T> for buffers.
5.2 Long-Running Background Service
Background services (IHostedService) often use timers or loops. If you capture state in a lambda passed to a timer, that state stays alive. Use a state object and pass it to the timer callback. Also, be careful with static caches—implement expiration or bounded size.
5.3 Legacy Windows Forms / WPF Application
UI frameworks are prone to event leaks because controls subscribe to events from long-lived models. Use WeakEvent pattern or manually unsubscribe in the FormClosing event. Also, large images or bitmaps should be disposed immediately after use, because they hold unmanaged memory.
6. Pitfalls, Debugging, and What to Check When It Fails
Even after applying the fixes, memory issues can persist. Here are common pitfalls and debugging steps.
6.1 The Finalizer Thread Blocked
If a finalizer throws an exception, the finalizer thread is crippled, and all subsequent finalizable objects queue up. Check the application event log for finalizer exceptions. Use a try-catch in finalizers to log but not crash. Better: avoid finalizers altogether by using SafeHandle.
6.2 WeakReference Misuse
WeakReference lets you reference an object without preventing collection, but the object can be collected at any time. If you dereference a WeakReference and then use the object, it may be collected between the check and the use. Always copy the reference to a local variable and check for null. Also, WeakReference does not protect against resurrection in finalizers.
6.3 ThreadStatic and AsyncLocal Leaks
ThreadStatic fields are per-thread, but if a thread is reused from a pool, the stale value remains. AsyncLocal flows with the execution context, which can cause unexpected object retention across async calls. Clear these in a finally block.
6.4 What to Check When the Fix Doesn't Work
Take a memory dump using procdump or dotnet-dump. Load it into WinDbg with SOS extension. Run !dumpheap -stat to see the most common types. Look for types that should be transient but appear in large numbers. Then run !gcroot on one instance to see the reference chain. Common hidden roots: static variables, ThreadPool threads, and finalizer queue.
7. FAQ: Common Questions About C# Memory Management
We answer the questions that come up most often in our community.
7.1 Should I implement IDisposable on every class?
No. Only implement IDisposable if your class directly holds unmanaged resources or owns disposable fields. Over-implementing adds complexity and can lead to false positives in code analysis. Use the 'sealed' version of the pattern to avoid the virtual Dispose(bool) overhead.
7.2 Does GC.Collect() help?
Calling GC.Collect() manually can temporarily reduce memory, but it usually hurts performance by promoting objects prematurely and causing unnecessary collections. Rely on the GC's heuristics. The only legitimate use is in testing to force a collection before taking a snapshot.
7.3 How do I detect a memory leak in production without a profiler?
Monitor private bytes and Gen2 collection count. If private bytes grow steadily while Gen2 collections are frequent, you likely have a leak. Take a dump and analyze offline. For .NET Core, use dotnet-counters to watch GC heap size live.
7.4 What is the difference between a memory leak and memory bloat?
A leak is memory that is never freed (logical leak or unmanaged leak). Bloat is memory that is freed but allocated in excess, causing high GC overhead. Both hurt performance, but the fixes differ: leaks require removing references, bloat requires reducing allocation rate or reusing objects.
8. Next Steps: A Specific Action Plan
You now have a toolkit of six gotchas and their patches. Here is what to do next.
- Run a memory profiler on your application under load. Identify the top three types by size and count. For each, trace the reference chain to a GC root.
- Audit your codebase for the six patterns: static events, captured variables, missing Dispose calls, LOH allocations, string concatenation in loops, and unmanaged handles. Use a static analyzer like Roslyn analyzers to automate detection.
- Fix the most impactful leaks first—usually event handlers and captive DI dependencies yield the biggest memory reduction.
- After each fix, run the profiler again and compare heap snapshots. Confirm that the target objects are no longer present.
- Set up monitoring: add a health check endpoint that reports GC heap size and collection counts. Alert when heap size exceeds a baseline threshold.
- Share this checklist with your team during code reviews. Make memory leak prevention part of your definition of done.
Memory management in C# is not magic—it is a set of patterns you can learn and enforce. By systematically eliminating these six gotchas, you will reduce GC pressure, lower latency, and increase the reliability of your applications. Start with one service, measure the impact, and expand from there.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!