C# Event Subscription: Syntax, Pitfalls, and Clean Unsubscription
c# event subscription: Understand C# event subscription syntax, unsubscription, and memory leak prevention with practical code examples.
c# event subscription requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
In C#, event subscription is the mechanism that connects a publisher to a subscriber, but the syntax is only half the story. The more important half is knowing when and how to unsubscribe. A forgotten unsubscribe is one of the most common sources of memory leaks in long-running applications. This article covers the subscription operators, the consequences of ignoring unsubscription, and the patterns that keep event-driven code maintainable.
Event Subscription Syntax: The += and -= Operators
The core of C# event subscription is the += operator, which attaches a handler method to an event. The -= operator removes it. Both operators work on the event's underlying delegate list. Here is a minimal example:
public class Button { public event EventHandler? Click; public void SimulateClick() { Click?.Invoke(this, EventArgs.Empty); } } public class Form { private readonly Button _button = new(); public Form() { _button.Click += OnButtonClick; } private void OnButtonClick(object? sender, EventArgs e) { Console.WriteLine("Button clicked"); } public void Detach() { _button.Click -= OnButtonClick; } }
The += and -= operators are the only way to add or remove handlers from outside the declaring class. The event itself is a special kind of delegate field, and the compiler enforces that only += and -= are allowed from external code. This prevents external code from replacing the entire invocation list or invoking the event directly.
When you use +=, the compiler translates it to a call to the event's add accessor. Similarly, -= calls the remove accessor. For a field-like event, the compiler generates a backing delegate field and a pair of accessors that use Delegate.Combine and Delegate.Remove internally.
Why Unsubscription Matters: Memory Leaks and Stale Handlers
If a subscriber never unsubscribes, the publisher holds a strong reference to the subscriber through the delegate. As long as the publisher is alive, the subscriber will not be garbage collected, even if no other references exist. This is a classic memory leak in event-driven applications.
Consider a window that subscribes to a static event:
public static class AppEvents { public static event EventHandler? UserLoggedIn; } public class Dashboard { public Dashboard() { AppEvents.UserLoggedIn += OnUserLoggedIn; } private void OnUserLoggedIn(object? sender, EventArgs e) { // Refresh UI } }
If a Dashboard instance is created and then closed, but the subscription is never removed, the static AppEvents class keeps a reference to the Dashboard instance. The instance stays in memory, and its finalizer (if any) never runs. Over time, this accumulates and can cause memory pressure or unexpected behavior because the handler still executes.
The fix is to unsubscribe in a deterministic place, such as a Dispose method or when the subscriber's lifetime ends. For instance:
public class Dashboard : IDisposable { public Dashboard() { AppEvents.UserLoggedIn += OnUserLoggedIn; } public void Dispose() { AppEvents.UserLoggedIn -= OnUserLoggedIn; } private void OnUserLoggedIn(object? sender, EventArgs e) { // Refresh UI } }
Always pair every += with a corresponding -= in the appropriate lifecycle method. This is not just a theoretical concern; it directly affects the memory footprint of desktop, server, and mobile applications.
Subscribing with Anonymous Methods and Lambda Expressions
Anonymous methods and lambda expressions are convenient for short-lived subscriptions, but they introduce a subtle problem: you cannot easily unsubscribe because you do not have a reference to the delegate instance.
button.Click += (sender, e) => Console.WriteLine("Clicked");
To unsubscribe, you must store the delegate in a local variable:
EventHandler handler = (sender, e) => Console.WriteLine("Clicked"); button.Click += handler; // Later button.Click -= handler;
If you subscribe with a lambda and never store it, you cannot remove that exact delegate. Even if you write the same lambda expression again, it creates a new delegate instance that is not equal to the one already subscribed. The only way to remove it is to store it.
This is especially dangerous in long-lived publishers. If you subscribe with a lambda inside a method that runs repeatedly, you accumulate multiple handlers. For example:
public void Refresh() { _button.Click += (sender, e) => HandleClick(); }
Each call to Refresh adds a new handler. The old handlers remain, so HandleClick runs multiple times. The solution is to either store the handler as a field or avoid subscribing in repeated methods.
Event Accessors: Custom add and remove Blocks
Sometimes you need to control what happens when a handler is added or removed. You can define custom event accessors instead of using the default field-like event. This is useful for logging, validation, or forwarding subscriptions to another object.
private EventHandler? _clickHandlers; public event EventHandler? Click { add { Console.WriteLine("Handler added"); _clickHandlers += value; } remove { Console.WriteLine("Handler removed"); _clickHandlers -= value; } }
Custom accessors also allow you to store handlers in a non-standard way, such as a List<EventHandler> or a weak reference collection. However, they do not change the subscription syntax; callers still use += and -=. The accessors give you full control over the underlying storage and side effects.
A common use case is to implement an event that only allows a single subscriber, or to add thread safety around the add/remove operations. The default field-like event is not thread-safe in the sense that concurrent += and -= calls can corrupt the delegate chain. Custom accessors can lock a synchronization object to make the operations atomic.
Thread Safety and Event Invocation
Event subscription and unsubscription are not atomic operations by default. If multiple threads add or remove handlers concurrently, the delegate chain can become corrupted. The += and -= operators are not guaranteed to be thread-safe on a field-like event.
To make subscription thread-safe, you can use a lock inside custom accessors:
private readonly object _eventLock = new(); private EventHandler? _clickHandlers; public event EventHandler? Click { add { lock (_eventLock) { _clickHandlers += value; } } remove { lock (_eventLock) { _clickHandlers -= value; } } }
However, thread safety during invocation is a separate concern. When you raise an event, you typically copy the delegate reference to a local variable to avoid a NullReferenceException if another thread unsubscribes during invocation:
EventHandler? handler = Click; handler?.Invoke(this, EventArgs.Empty);
Even with this pattern, if a handler unsubscribes itself during the invocation, the copy still contains that handler, so it will be called. This is usually the desired behavior, but it means that unsubscription does not take effect until the next invocation.
Weak Event Pattern for Long-Lived Publishers
When a publisher has a longer lifetime than its subscribers, the strong reference held by the event prevents subscribers from being collected. The weak event pattern solves this by storing handlers in weak references. When the subscriber is garbage collected, the weak reference becomes invalid, and the handler is not invoked.
Implementing a weak event from scratch is complex because you need to store both the delegate and its target. A simpler approach is to use the WeakEventManager class, which is available in WPF and also in .NET Core via the Microsoft.Windows.SDK.NET package. For general .NET, you can use the WeakEventManager from the System.Windows namespace, but it is not available in all environments.
A manual implementation might look like this:
public class WeakEvent<TEventArgs> where TEventArgs : EventArgs { private readonly List<(WeakReference target, MethodInfo method)> _handlers = new(); public void AddHandler(EventHandler<TEventArgs> handler) { _handlers.Add((new WeakReference(handler.Target), handler.Method)); } public void RemoveHandler(EventHandler<TEventArgs> handler) { // Find and remove matching entry } public void Raise(object sender, TEventArgs e) { foreach (var (targetRef, method) in _handlers.ToList()) { var target = targetRef.Target; if (target == null) { _handlers.RemoveAll(h => h.method == method && h.targetRef == targetRef); continue; } method.Invoke(target, new object[] { sender, e }); } } }
This is a simplified version; a production implementation must handle method removal, avoid memory leaks in the handler list, and consider thread safety. The weak event pattern is most useful when you have a long-lived publisher and many short-lived subscribers, such as a global messaging service or a static event hub.
Common Pitfalls in Event Subscription
One common mistake is subscribing to an event on an object that is created and destroyed frequently, without unsubscribing. For example, in a loop that creates new instances, each instance subscribes to a shared event. If the instance is not disposed, the shared event accumulates references.
Another pitfall is subscribing to events on static classes or singletons. Since the publisher never goes out of scope, the subscriber must explicitly unsubscribe. If the subscriber is also a singleton, the leak may not be noticeable, but if subscribers are transient, the memory grows.
A subtle issue arises when using method groups. If you subscribe with a method group and later try to unsubscribe with a different method group that points to the same method, the delegate equality check works because the target and method are the same. However, if the method is an instance method and the instance is different, they are not equal. Always use the same delegate instance for both subscribe and unsubscribe.
Finally, be careful with event handlers that capture local variables in lambdas. The lambda captures the variable, not its value at subscription time. If the variable changes, the handler sees the latest value. This can lead to unexpected behavior if you expect the handler to use the value at the moment of subscription. Store the value in a local copy if needed.
Event subscription is a powerful tool, but it requires discipline. Always pair += with -=, store anonymous delegates if you need to remove them, and consider the lifetime of both publisher and subscriber. When in doubt, use the weak event pattern to decouple lifetimes.