C# Delegates and Events — The 500MB/Day Memory Leak Pattern
Static events holding subscriber references caused 500MB/day memory growth until OOM.
- A delegate is a type-safe reference type that holds a method's signature and target object.
- Events are delegates with restricted access — only the declaring class can invoke them; subscribers can only add or remove.
- Multicast delegates are immutable: each
+=or-=creates a new delegate instance with a new invocation list. - Performance: delegate invocation adds ~10ns overhead vs direct call. Allocation from resubscription is the real cost.
- Production pitfall #1: raising an event without the null check pattern throws
NullReferenceExceptionwhen there are no subscribers. - Production pitfall #2: event subscriptions create strong references — failing to unsubscribe keeps the subscriber alive forever.
- Biggest mistake: treating events as sugar for delegate fields. The encapsulation prevents accidental clearing of all handlers.
Imagine you hire a personal assistant and tell them: 'Whenever my phone rings, do THIS.' You haven't picked up the phone yet — you're just registering an instruction in advance. A delegate is that instruction card. An event is the rule that says only YOU can hand out those instruction cards, but anyone can write their name on one. When the phone rings, every person who signed up gets notified automatically.
Hardcoding reactions directly into event sources creates coupling so tight your codebase becomes unmaintainable. Delegates and events are C#'s language-level solution to the Observer pattern, with type safety, multicast support, and access control you can't get from hand-rolled interfaces.
The CLR implements multicast delegates as an immutable linked list — every += allocates a new delegate. Events add an encapsulation layer that stops subscribers from wiping each other's registrations. Misunderstand this distinction and you'll leak memory at 500MB/day or chase NullReferenceException for weeks.
This article covers how delegates allocate under load, why events are not just syntactic sugar, thread-safe invocation that won't throw, and the debug commands that find leaking event roots in memory dumps.
The pattern originated from .NET's design guidelines for component-oriented programming. Since .NET Framework 1.0, events have been the backbone of UI frameworks like WinForms and WPF. In modern ASP.NET Core, events still drive real-time notifications through SignalR and background services. The same rules apply.
What is Delegates and Events in C#?
Rather than starting with a dry definition, let's see it in action and understand why it exists. The core idea is simple: instead of hardcoding actions when something happens, you pass methods as arguments. That's the delegate. When multiple objects need to react to a single action, you wrap it in an event. You'll see this pattern everywhere: button clicks, data change notifications, async progress reporting.
The problem they solve is the Observer pattern, but baked into the language with type safety and multicast support. Without them, you'd be managing lists of IListener objects by hand, wiring up callbacks and cleaning up manually. Delegates collapse that boilerplate into a single type-safe function pointer.
Here's the C# equivalent using the io.thecodeforge namespace:
```csharp using System;
namespace io.thecodeforge.DelegateIntro { public delegate void LogHandler(string message);
public class EventExample { public static void Main() { LogHandler log = Console.WriteLine; log += msg => Console.WriteLine($"[TS] {DateTime.Now}: {msg}"); log("First call"); } } } ```
The delegate pattern is especially powerful in .NET because it is language-integrated. Unlike Java's functional interfaces or C++'s function pointers, C# delegates are reference types with runtime support for multicast and covariance. This means you can build extensibility points without any external library.
One real angle: delegates also enable callback-based async patterns that predate async/await. The BeginInvoke/EndInvoke pattern (now deprecated) used delegates for asynchronous programming. Understanding delegates helps you read legacy code and understand why the modern patterns evolved.
Another important point: the CLR treats delegates as first-class citizens. They have their own vtable slot and are subject to JIT optimizations. However, that also means each delegate allocation is a heap object — in tight loops, consider caching the delegate.
Beyond the basics, delegates support generic type parameters: Func<T, TResult> and Action<T> are just pre-defined generic delegates. They're used in LINQ, tasks, and countless APIs. The CLR's runtime handles generic instantiation specially — each combination of type parameters produces a new delegate class at runtime, which adds to the memory footprint if you create many variants.
Here's the thing: generic delegates like Func<T> are powerful, but each type combination creates a new delegate class at runtime. If you're calling a delegate inside a tight loop, that allocation adds up. Cache your delegate instances.
Delegate Internals: The Immutable Multicast Chain
A delegate in C# is a sealed class derived from System.MulticastDelegate. When you assign a method to a delegate, the CLR creates an invocation list — a linked list of Delegate objects. Each node holds a target object and a method pointer. The list is immutable: adding or removing a handler creates a new delegate instance with a new list, leaving the original unchanged. This immutability is why you can safely read the delegate on one thread while another thread modifies it, as long as you copy the reference first.
The invocation list is walked sequentially when the delegate is invoked. That means if one handler throws, subsequent handlers never run — a critical detail for event patterns that need exception isolation.
Performance-wise, each += on a delegate with n handlers allocates a new multicast delegate of size O(n). If you subscribe and unsubscribe frequently, you're piling allocation pressure on the GC. In a high-frequency trading feed handler, we measured 2% CPU time spent in delegate allocation from periodic resubscriptions. The fix: pool delegates or batch subscribe once.
There's another hidden cost: the Combine and Remove static methods on Delegate are O(n) because they traverse the entire invocation list. This matters when you have thousands of subscribers. A telemetry library once overwhelmed its own GC by repeatedly adding and removing handlers on a static event. The escalation fix was to swap to a ConcurrentDictionary<EventHandler, Action> and invoke through it, bypassing multicast delegation entirely.
Additionally, delegates support variance — covariance in return types and contravariance in parameter types. This is essential for generic event handlers. For example, an EventHandler<EventArgs> can be assigned to an EventHandler<CustomEventArgs> if CustomEventArgs derives from EventArgs, thanks to delegate covariance.
One more internal detail: the Target property of a delegate points to the object instance on which the method is invoked. For static methods, Target is null. This is how the CLR binds the this reference. When you inspect delegates in a debugger, the Target property tells you which object is being held alive.
The CLR also caches the most recently used delegate creation across AppDomains? No, but it does use a per-delegate-type vtable slot for the Invoke method. Understanding this helps when performance-tuning delegate-heavy code.
- Each
+=allocates a newMulticastDelegatewith a new invocation list that includes the old entries plus the new one. -=similarly creates a new delegate with the entry removed, unless the list becomes empty — then you getnull.- Immutable means no thread safety problems with simultaneous reads, but it also means allocation on every subscription change.
- Performance: adding/removing handlers allocates O(n) memory for the new list when there are n existing handlers.
- This allocation matters in hot paths: if you subscribe/unsubscribe in a tight loop, you'll generate GC pressure proportional to the list size.
GetInvocationList() and invoke each delegate inside a try-catch.event) — faster but less fault-tolerant.Events vs Delegates: Why Events Are Not Just Fields
An event declaration like public event EventHandler SomethingHappened; creates a field-like event — the compiler generates a private delegate field and public add/remove accessors. Subscribers can only use += and -= on the event; they cannot read, assign null, or invoke it outside the declaring class. This encapsulation prevents the classic bug where a subscriber accidentally clears all other subscribers with MyEvent = null; or invokes the delegate from outside.
But events are more than syntactic sugar: the accessor pattern allows you to customise how handlers are stored (e.g., using a list of weak references, or logging every subscription). The compiler also generates the backing field visibility rules. The rule is simple: if you need subscribers to only add/remove themselves, use an event. If you need to pass a method pointer freely (like a callback), use a plain delegate.
A common misconception is that events are thread-safe because they are delegates. They are not — both += and -= on a field-like event are thread-safe only if the backing field is accessed with Interlocked.CompareExchange. The compiler does this for field-like events, but custom accessors must implement thread safety themselves.
In practice, custom event accessors are rare but powerful. For example, you can store handlers in a SortedList to guarantee invocation order — something the standard event doesn't support. You can also add logging to every subscription for audit trails.
One real-world case: a logging library exposed a public delegate field for its log event. A misbehaving plugin set it to null during initialization, wiping all subscribers. The fix was a one-character change — swap public EventHandler to public event EventHandler. The lesson: when in doubt, use event. You can always expose a delegate internally if you need the flexibility.
Custom event accessors also allow you to enforce invariants. For example, you can prevent double-subscription by checking if the handler is already in the invocation list. However, checking GetInvocationList() for each add is O(n), so be mindful.
Another subtle point: events are not virtual. You cannot override an event in a derived class. If you need polymorphic events, consider using a protected virtual method that raises the event, and allow derived classes to override that method.
Events also participate in the type system differently. An event's delegate type is not the same as its underlying field — the add/remove accessors are separate. This matters when you try to pass an event to a method expecting a delegate. You need to explicitly reference the field or use the event's accessor.
GetInvocationList().Contains() check before adding.Thread-Safe Event Invocation: Avoiding the NullReference Trap
Raising an event with subscribers on multiple threads introduces a race condition: between the null check and the invocation, another thread could unsubscribe, setting the backing field to null. The classic pattern
``csharp if (SomeEvent != null) SomeEvent(this, EventArgs.Empty); ``
is not thread-safe. The fix is to copy the delegate reference to a local variable before the null check:
``csharp var handler = SomeEvent; if (handler != null) handler(this, EventArgs.Empty); ``
Because delegates are immutable, the local copy retains the original invocation list even if another thread modifies the event field. This pattern is so standard that the C# compiler generates it for field-like events when you use the null-conditional operator ?.:
``csharp SomeEvent?.Invoke(this, EventArgs.Empty); ``
?. performs a single read of the delegate, checks for null, and if non-null, invokes. It is effectively the same as the local copy pattern, but more concise.Invoke()
However, even this pattern has a subtle issue: if the delegate is multicast and one handler unsubscribes another, the local copy still contains the old list — safe, but might invoke a handler that was just removed. Usually this is acceptable because the handler is still a valid object.
One more gotcha: if you use ?. inside a lock, you're reading the delegate outside the lock, then invoking inside. That's fine because the local copy is immutable. But never read and invoke separately without a local copy — that's the guarantee that makes the pattern safe.Invoke()
In a real incident, a trading system's OnPriceUpdate event was raised inside a lock that also changed the subscriber list. The team used if (OnPriceUpdate != null) OnPriceUpdate(...) and got a NullReferenceException every few hours during high volatility. Switching to ?. eliminated the race. Always use the local copy — it's not just best practice, it's a production requirement.Invoke()
Another thread-safety concern: even with the local copy pattern, if your handler does heavy work and the publisher continues to raise the event, the local copy may become outdated for the next raise. That's usually fine, but if you need to synchronize the raise itself with subscription changes, consider using a lock around the raise and the add/remove. However, locks can cause contention and deadlocks if handlers try to subscribe/unsubscribe during invocation.
A deeper point: the Volatile.Read method can be used to ensure the delegate field is read fresh from memory, preventing JIT reordering. In extreme high-concurrency scenarios, use Volatile.Read(ref someEvent) before the local copy.
Also note: the local copy pattern works because references in .NET are read atomically. On 32-bit systems, referencing a delegate field might require two memory reads if the field is not aligned. Volatile.Read solves alignment issues too.
Memory Leaks from Event Subscriptions: The Silent Killer
The most common production bug with events is the memory leak: a subscriber object that is no longer needed cannot be garbage-collected because the publisher holds a strong reference to it through the delegate's invocation list. This is the event equivalent of forgetting to remove an observer in the classic pattern.
Consider a UI application where a manager class subscribes to a long-lived service's event. Even after the manager is dismissed (e.g., closing a window), the manager object remains alive because the service's event delegate still references it. The manager's finalizer never runs, and all its resources stay allocated.
A production incident at a logistics company: a weather service event fired every 5 minutes, and each UI window that subscribed stayed in memory forever. After a week, the process consumed 4 GB. The fix was simple: always unsubscribe in Dispose().
To detect this: take a memory dump and use dotnet-dump analyze to run !DumpHeap -type EventHandler or !GCRoot on suspected objects. Look for delegate objects that reference objects that should have been collected.
Solutions: 1. Explicit unsubscribe: The subscriber must call -= on the event when it is done (e.g., in Dispose()). 2. Weak Event Pattern: Use WeakEventManager (WPF) or a custom implementation where the publisher holds a weak reference to the subscriber. When the subscriber is collected, the weak reference becomes null and the handler is skipped. 3. Event Aggregator: Use a mediator that manages subscriptions centrally, allowing unsubscription on disposal. 4. Lifetime management: If the subscriber has a known shorter lifetime than the publisher, always unsubscribe when the subscriber's lifetime ends.
Also consider using dotMemory or PerfView to analyze delegate roots in memory dumps.
One extra angle: anonymous method closures that capture local variables can also cause leaks. If you write service.Something += (s,e) => { ... } and the lambda captures a large object, that object stays alive as long as the subscriber is subscribed. Be explicit — capture only what you need, or use a method group instead of a lambda when possible.
Another subtle leak is when you subscribe with a lambda that captures the subscriber itself. Even if you unsubscribe the lambda, if you don't store the reference to the lambda, you can't unsubscribe later. Always store the delegate in a field if you plan to unsubscribe.
Also: the WeakEventManager in WPF uses a dictionary of weak references, which itself can become large if many subscriptions are made. The cleanup of dead references happens on each invocation, but if events are rare, the dictionary can accumulate stale entries. Consider periodic manual cleanup.
A more advanced pattern: use ConditionalWeakTable to associate handlers with objects without preventing GC. This is used by some MVVM frameworks to automatically unsubscribe when a view is collected.
The Memory Leak That Took a Trading Platform Down
Dispose() method using -= operator. Switch to a weak event pattern using WeakReference or the WeakEvent pattern for long-lived publishers. Added a finalizer guard to log if Dispose was missed.- Event subscriptions create strong references from publisher to subscriber — the subscriber cannot be GC'd until it unsubscribes.
- Always unsubscribe in
Dispose()when the subscriber implements IDisposable. - For long-lived publishers with short-lived subscribers, use the Weak Event pattern (WeakEventManager, or custom weak handler).
- Monitor memory dumps for delegates that reference unexpected objects — WinDbg command
!DumpHeap -type EventHandlershows all live delegates. - When debugging, also check for anonymous method closures that capture large objects — they create the same leak pattern.
handler?.Invoke(). Use Volatile.Read if event is accessed from multiple threads to avoid torn read.dotnet-dump collect. Use dotnet-dump analyze and run dumpheap -type EventHandler to list all delegates. Look for unexpected roots.Delegate.GetInvocationList() to inspect current subscribers.public delegate void MyEventHandler() and always check null before invoking.That's C# Advanced. Mark it forged?
11 min read · try the examples if you haven't