Delegates, Events & Multicast Chains
How .NET delegates are type-safe function pointers β and why forgetting to unsubscribe causes memory leaks.
A delegate in .NET is not just a function pointer β it's a full object that carries method references, supports multicast (multiple subscribers), and is type-checked at compile time. The event keyword turns a delegate field into a publication/subscription channel with enforced access rules. Understanding how the invocation list works β and who owns references to whom β is essential for writing production-grade .NET code.
A delegate is an object that wraps a method reference β think of it as a strongly-typed function pointer. The CLR validates at compile time that the method's signature matches the delegate's signature, unlike raw function pointers in C++.
Every delegate instance has an InvocationList β an array of method references. When you use +=, the CLR creates a new combined delegate (delegates are immutable). When invoked, each method in the list is called in order. This is the multicast pattern.
Declaring a field as 'event' prevents external code from doing two dangerous things: assigning directly (OnOrderPlaced = null) and invoking it (OnOrderPlaced(...)). Only the containing class can invoke the event. External code can only subscribe (+= ) and unsubscribe (-=).
If any handler in the invocation list throws an unhandled exception, the remaining handlers are NOT called β execution stops at the faulting handler. For resilient event dispatch, call GetInvocationList() and invoke each delegate in a try/catch block.
When you subscribe with +=, the publisher's delegate holds a reference to your subscriber object (capturing 'this'). As long as the publisher is alive, the subscriber cannot be garbage-collected β even if all other references to it are gone. Always unsubscribe in Dispose().
Key Concepts
An object encapsulating a method reference with a specific signature. Sealed subclass of System.MulticastDelegate. Can be null, combined with +, or compared for equality.
The internal array of delegate targets inside a MulticastDelegate. GetInvocationList() returns it. += creates a new combined delegate; delegates are immutable.
A modifier that restricts a delegate field: external code can only += and -=. Prevents external code from clearing all subscriptions or invoking the event directly.
The standard BCL delegate: void EventHandler<TEventArgs>(object? sender, TEventArgs e). Use this pattern for all .NET events β it's consistent and compatible with WPF, WinForms, and ASP.NET.
WeakReference-based subscription (WeakEventManager in WPF) lets the GC collect the subscriber even if the publisher is alive. Solves the memory leak problem at the cost of complexity.
The null-conditional invoke pattern. OnOrderPlaced?.Invoke(this, e) is the thread-safe way to raise an event β it reads the delegate into a local variable before the null check, preventing a race where another thread unsubscribes between the check and the call.
1// Type-safe delegate declaration β this IS the contract2public delegate void OrderPlacedHandler(object sender, OrderPlacedEventArgs e);34// Or use the built-in EventHandler<T> (preferred)5public class OrderService6{7 // 'event' keyword = access modifier: external code can only += and -=8 // Without 'event', anyone can do OnOrderPlaced = null; β catastrophic9 public event EventHandler<OrderPlacedEventArgs>? OnOrderPlaced;1011 public async Task PlaceOrderAsync(Order order, CancellationToken cancellationToken)12 {13 await _dbContext.Orders.AddAsync(order, cancellationToken);14 await _dbContext.SaveChangesAsync(cancellationToken);1516 // β οΈ this bites everyone eventually β always use ?.Invoke()17 // OnOrderPlaced(this, new OrderPlacedEventArgs(order)); // throws if null18 OnOrderPlaced?.Invoke(this, new OrderPlacedEventArgs(order));19 }20}2122// Subscriber β in a long-lived service, this causes a MEMORY LEAK23// The publisher holds a ref to the subscriber via the delegate24public class EmailNotificationService25{26 public void Subscribe(OrderService orderService)27 {28 orderService.OnOrderPlaced += HandleOrderPlaced; // += captures 'this'29 }3031 // DON'T forget to unsubscribe when this service is disposed32 public void Dispose()33 {34 // If OrderService outlives EmailNotificationService and you don't35 // unsubscribe, EmailNotificationService is never GC'd β memory leak36 _orderService.OnOrderPlaced -= HandleOrderPlaced;37 }3839 private void HandleOrderPlaced(object? sender, OrderPlacedEventArgs e)40 {41 _emailClient.SendAsync(e.Order.CustomerEmail, "Order confirmed", ...);42 }43}4445// Multicast delegate β the invocation list46Action<string> logger = Console.WriteLine;47logger += (msg) => File.AppendAllText("log.txt", msg); // second handler48logger += (msg) => _metrics.Increment("messages"); // third handler4950// All three are called in order of subscription51// If handler[0] throws, handler[1] and [2] are NEVER called52// Use GetInvocationList() to call each handler in a try/catch if you need resilience
Events are everywhere in .NET: UI frameworks (WPF, WinForms, MAUI), ASP.NET Core middleware hooks, EF Core interceptors, and domain-driven design domain events all use this pattern. Getting it right β using ?.Invoke() for thread safety, unsubscribing in Dispose(), and handling partial failures β is the difference between solid production code and a memory leak waiting to happen.
Common Pitfalls
1The Friday Night Memory Leak
Our ASP.NET Core background service was leaking ~50 MB per hour in production. Memory profiler showed thousands of live instances of ReportEmailer β a service that should have been disposed after each job run.
JobSchedulerService registered ReportEmailer instances as event subscribers (schedulerService.OnJobComplete += emailer.Notify) but never unsubscribed. Because JobSchedulerService was a Singleton (alive for the app lifetime), its delegate's invocation list held strong references to every ReportEmailer ever created. None could be GC'd.
Added IDisposable to ReportEmailer with _schedulerService.OnJobComplete -= Notify in Dispose(), and registered it as Scoped so the DI container called Dispose() after each request scope. The weak event pattern (WeakEventManager) was considered but deemed overkill for this case.
Takeaway: In .NET, 'publisher outlives subscriber' is the #1 cause of event-related memory leaks. If your subscriber is shorter-lived than the publisher, you MUST unsubscribe β the GC will not save you.
2Invocation List Exception Swallowing
Our e-commerce platform had an OrderPlaced event with 4 handlers: logging, inventory reservation, email notification, and analytics. The logging handler started throwing intermittently due to a log sink outage. Result: inventory was never reserved and customers received no confirmation emails for ~2 hours.
C# multicast delegates abort the entire invocation list on first exception. LoggingHandler was at index 0. When it threw, InventoryHandler[1] and EmailHandler[2] were silently skipped. The exception was caught at the top of PlaceOrder and logged as 'logging failed' β nobody noticed that orders were being accepted but not fulfilled.
Replaced the single event raise with explicit GetInvocationList() iteration with per-handler try/catch. Each handler failure is logged independently and does not block subsequent handlers. Critical handlers (inventory) were moved to a separate, guaranteed execution path using an outbox pattern.
Takeaway: Never assume all handlers in a multicast chain will execute. Either wrap each in try/catch via GetInvocationList(), or use a proper message bus (MediatR, NServiceBus) for production event dispatch where partial failure handling matters.