Skip to main content
C# Memory Management Gotchas

Event Handler Hoarding: The Silent Memory Leak FunHive Unsubscribed From

Event handlers are one of the most convenient features in C#—they let objects communicate without tight coupling. But that convenience comes with a hidden cost: if you don't unsubscribe, you can accidentally keep objects alive long after they should have been garbage collected. This is event handler hoarding, a memory leak that's easy to introduce and hard to spot. Here we'll show you exactly how it happens, why it's so common in .NET applications, and concrete steps to prevent it. If you've ever noticed a desktop app growing sluggish after hours of use, or a background service consuming more memory over days, event hoarding might be the culprit. The problem is especially insidious because it doesn't crash your app—it just slowly degrades performance until restarts become necessary. Time to pull back the curtain on this silent memory leak. Why Event Handler Hoarding Matters Right Now Modern .

Event handlers are one of the most convenient features in C#—they let objects communicate without tight coupling. But that convenience comes with a hidden cost: if you don't unsubscribe, you can accidentally keep objects alive long after they should have been garbage collected. This is event handler hoarding, a memory leak that's easy to introduce and hard to spot. Here we'll show you exactly how it happens, why it's so common in .NET applications, and concrete steps to prevent it.

If you've ever noticed a desktop app growing sluggish after hours of use, or a background service consuming more memory over days, event hoarding might be the culprit. The problem is especially insidious because it doesn't crash your app—it just slowly degrades performance until restarts become necessary. Time to pull back the curtain on this silent memory leak.

Why Event Handler Hoarding Matters Right Now

Modern .NET applications are increasingly long-lived. Desktop tools, Windows services, ASP.NET Core background tasks, and even Unity games often run for hours or days without restarting. In these environments, even small memory leaks accumulate into serious problems. Event handler hoarding is one of the most common causes of such leaks, yet many developers don't recognize it until their app becomes unresponsive.

Consider a typical scenario: a UI form subscribes to a static event from a service locator. The form is closed, but the event handler still references it. The form's memory—including all its controls, data bindings, and associated objects—cannot be collected. Over time, every opened and closed form adds to the leak. We've seen applications where memory usage doubled every hour due to this pattern alone.

The core reason is simple: a delegate (the event handler) holds a reference to its target object. As long as the publisher object is alive and the delegate is registered, the subscriber cannot be collected. The publisher acts like a root in the garbage collector's reachability graph. This is different from a circular reference, which the GC can handle; here, the publisher is reachable from a GC root, making the subscriber permanently reachable.

Why does this happen so often? Because event handlers are typically added in constructors or initialization code, and developers forget to remove them in cleanup. The symmetry of subscribe/unsubscribe is easy to overlook, especially when the codebase grows. Teams often assume that disposing the subscriber will handle cleanup, but if the publisher outlives the subscriber, the subscription remains active.

Another factor is the rise of reactive patterns and event-driven architectures. While these patterns improve responsiveness, they also increase the number of event subscriptions. Each subscription is a potential leak point. Without careful discipline, an application can accumulate hundreds of orphaned handlers, each holding a reference chain that prevents garbage collection.

The good news is that detecting and fixing event hoarding is straightforward once you know what to look for. Memory profilers like dotMemory or PerfView can show you which objects are unexpectedly retained. The fix often involves implementing IDisposable, using weak events, or simply ensuring symmetric unsubscribe calls. Let's look at the mechanics first.

Who Should Pay Attention

This article is for any C# developer working on applications that run for more than a few minutes. If you write Windows Forms, WPF, Unity scripts, or long-running console services, event hoarding is a risk you need to manage. Even ASP.NET Core applications can suffer if they use static events or long-lived singletons that subscribe to short-lived objects.

Core Idea: How a Simple Delegate Becomes a Memory Leak

At its heart, an event in C# is a multicast delegate. When you subscribe with +=, you're adding a delegate instance to an invocation list. That delegate holds two critical pieces: a reference to the method (the MethodInfo) and a reference to the target object (the Target property). For static methods, Target is null; for instance methods, it's the object on which the method is defined.

The leak occurs when the publisher's event delegate chain holds a reference to a subscriber that is no longer needed. As long as the publisher is reachable from a GC root (e.g., a static variable, a long-lived singleton, or a form that stays open), the subscriber will never be collected. The subscriber's entire object graph—all the objects it references—remains alive too.

This is not a circular reference problem. The GC handles cycles just fine. The issue is that the publisher acts as an external root. Imagine a static EventManager that publishes a DataChanged event. A DataGrid subscribes to that event in its constructor. When the DataGrid is closed and disposed, the EventManager still holds a delegate pointing to the DataGrid. The DataGrid cannot be collected until that delegate is removed.

The leak is silent because the application continues to work. The event handler might even execute on the disposed object, causing subtle bugs or exceptions. But the primary symptom is growing memory usage. Over time, the GC runs more frequently, CPU usage increases, and eventually the app may throw OutOfMemoryException.

Why the Garbage Collector Can't Help

The .NET GC is a tracing collector. It starts from roots (static fields, local variables on stack, CPU registers) and marks all objects reachable from them. Any object not marked is considered garbage. Event handlers create a path from a root (the publisher) to the subscriber. As long as that path exists, the subscriber is reachable, so the GC cannot reclaim it. The GC has no way to know that the subscriber is logically dead; it only sees references.

Common Misconception: Disposing the Subscriber Is Enough

Many developers believe that calling Dispose() on the subscriber will release the event handler. But Dispose() only releases unmanaged resources; it doesn't remove the event subscription. Unless the Dispose() method explicitly unsubscribes (-=), the publisher still holds the delegate. The subscriber remains in memory, and its Dispose() might not even be called if the finalizer doesn't run (which it won't, because the object is still reachable).

How It Works Under the Hood: The Reference Chain

To understand why event hoarding is so tenacious, let's trace the reference chain. Consider a publisher class Button with a Click event. A subscriber class Form subscribes: button.Click += OnClick;. Internally, the Button has a field like private EventHandler _click;. When you subscribe, the delegate is added to the invocation list. The delegate's Target property points to the Form instance.

Now, if the Button is stored in a static variable or a long-lived collection, the Form is reachable through: static root → Button → _click → delegate → Target → Form. The Form cannot be collected. Even if the Form is no longer displayed, it stays alive.

This chain is especially dangerous when the publisher is a static event or a singleton. Static events are rooted in the type itself, so they live for the entire application domain. Any subscriber to a static event that doesn't unsubscribe will live forever. This is a common pattern in event aggregators, message buses, and service locators.

Another subtle case is when the subscriber subscribes to an event on a short-lived publisher. For example, a long-lived Logger subscribes to an event on a Request object that is created and destroyed frequently. If the Logger doesn't unsubscribe, the Request objects pile up because the Logger holds a reference to each one. This is the reverse leak: the subscriber outlives the publisher, but the publisher's memory is still held.

Multicast Delegates and Invocation Lists

Each event's delegate is actually a MulticastDelegate that chains multiple delegates together. When you subscribe, a new delegate is added to the list. Unsubscribing removes it. The list is immutable; adding or removing creates a new list. This means that if you have many subscribers, the entire list is replaced on each subscription or unsubscription. But that doesn't affect the leak—the important part is that each delegate in the list holds a reference to its target.

Anonymous Methods and Lambda Closures

When you use a lambda or anonymous method as an event handler, the compiler generates a closure class that captures any local variables used. The delegate's target becomes an instance of that closure class. This adds another layer of indirection. If the closure captures a reference to the subscriber, the chain becomes even longer. Worse, if you forget to unsubscribe, the closure object keeps the subscriber alive. This is particularly tricky because the closure is an invisible compiler-generated class.

For example: button.Click += (s, e) => this.Update();. The lambda captures this, so the delegate's target is a closure that holds a reference to this. Unsubscribing requires saving the delegate: EventHandler handler = (s, e) => this.Update(); button.Click += handler; ... button.Click -= handler;. If you don't save the delegate, you can't unsubscribe cleanly.

Worked Example: A Realistic Walkthrough

Let's build a concrete example to see the leak in action. Suppose we have a MessageService that publishes events when messages arrive. It's a singleton accessed via MessageService.Instance. A ChatWindow subscribes to MessageService.Instance.MessageReceived to display new messages.

public class MessageService
{
public static MessageService Instance { get; } = new();
public event EventHandler<string> MessageReceived;
public void SendMessage(string msg) => MessageReceived?.Invoke(this, msg);
}

public class ChatWindow : IDisposable
{
public ChatWindow()
{
MessageService.Instance.MessageReceived += OnMessageReceived;
}
private void OnMessageReceived(object sender, string msg) => Console.WriteLine(msg);
public void Dispose() { /* no unsubscription */ }
}

The ChatWindow is created and shown, then closed. The Dispose method is called, but it doesn't unsubscribe. The MessageService.Instance (static, alive forever) still holds a delegate to the ChatWindow. The ChatWindow cannot be collected. If users open and close 100 chat windows, 100 windows remain in memory, each with its own UI controls and data.

To fix this, we need to unsubscribe in Dispose:

public void Dispose()
{
MessageService.Instance.MessageReceived -= OnMessageReceived;
}

But there's a catch: if Dispose is called from a finalizer (unlikely here), the event might still fire. A safer pattern is to use a flag to prevent handler execution after disposal. Also, ensure that Dispose is called deterministically, e.g., using a using block or explicit cleanup.

Detecting the Leak with a Memory Profiler

To confirm the leak, run the app and take a memory snapshot after opening and closing several chat windows. In a profiler like dotMemory, look for instances of ChatWindow that are still referenced. The retention path will show MessageService → _messageReceived → ... → ChatWindow. This confirms the event handler is the root cause.

Edge Cases and Exceptions

Not every event subscription causes a leak. If both publisher and subscriber are short-lived and die together, there's no problem. The leak only occurs when the publisher outlives the subscriber. Here are some common edge cases to watch for.

Static Events

Static events are the most dangerous because the publisher lives for the entire app domain. Any subscriber that doesn't unsubscribe will never be collected. This includes anonymous methods that capture local variables. Always unsubscribe from static events, or use weak event patterns.

Weak Events

The .NET Framework provides WeakEventManager for WPF, and you can implement a weak event pattern yourself. A weak event uses a weak reference to the subscriber, so the GC can collect the subscriber even if the publisher still holds the delegate. However, weak events have their own complexities: the delegate must be recreated if the subscriber is resurrected, and performance can suffer. They're best used for long-lived publishers with many short-lived subscribers.

Anonymous Lambda Handlers

As mentioned, lambdas that capture variables create compiler-generated closures. If you subscribe with a lambda and don't save the delegate, you cannot unsubscribe. This is a common source of leaks. The fix is either to save the delegate in a field or avoid capturing variables that keep objects alive.

Event Handlers on Disposed Objects

If an event fires after the subscriber is disposed, the handler might try to access disposed resources, causing ObjectDisposedException. To prevent this, check a disposal flag in the handler. This is especially important in UI frameworks where events can fire asynchronously.

Multithreading and Race Conditions

When subscribing or unsubscribing from multiple threads, you can encounter race conditions. The event delegate is immutable, so adding and removing is thread-safe in the sense that you won't corrupt the list, but you might miss an unsubscribe or subscribe. Use a lock or a SynchronizationContext if needed.

Limits of the Approach: When Unsubscribing Isn't Enough

While symmetric unsubscribe is the primary defense, it's not always sufficient. Here are scenarios where even careful unsubscription might not prevent leaks, and what to do about them.

Third-party libraries: If you're using a library that exposes events but doesn't document cleanup, you might not know when to unsubscribe. In such cases, wrap the subscription in a helper class that implements IDisposable and unsubscribes in its Dispose. Use a using block to manage the lifetime.

Event aggregators and messaging systems: Frameworks like Prism or MVVM Light use event aggregators that manage subscriptions. They often use weak references internally, but not always. Check the documentation. If they use strong references, you must unsubscribe manually. Consider switching to a weak-event-based aggregator.

Finalizers and resurrection: If a subscriber has a finalizer, the GC puts it on the finalization queue, which keeps it alive for one more collection. If the finalizer doesn't unsubscribe, the object can be resurrected if the event fires again. Avoid finalizers for cleanup; use IDisposable instead.

Performance overhead of unsubscription: Unsubscribing from an event with many subscribers can be expensive because it creates a new delegate list. If you have high-frequency subscriptions and unsubscriptions, consider using a different pattern, like a HashSet of handlers or a custom event implementation.

Now, what should you do next? Start by auditing your codebase for event subscriptions, especially to static events and singletons. For each subscription, ensure there's a corresponding unsubscribe in a predictable cleanup method (like Dispose). Use memory profilers to verify that objects are being collected. Consider implementing a base class that automates subscription management. Finally, educate your team about the pattern—it's a small habit that prevents big problems.

Event handler hoarding is a silent leak, but it's also one of the easiest to fix once you know the pattern. Unsubscribe symmetrically, use weak references when appropriate, and test with a profiler. Your app's memory—and your users' patience—will thank you.

Share this article:

Comments (0)

No comments yet. Be the first to comment!