AlgoMaster Logo

Multicast Delegates

Last Updated: May 22, 2026

Medium Priority
12 min read

A delegate in C# doesn't have to point at a single method. The same delegate instance can hold a list of methods, and invoking it runs all of them in order. That ability is called multicast, and it's the mechanism that makes one cart-updated notification reach the price tag, the cart icon, the analytics logger, and the recommendation engine without any of them knowing about each other.

Why Multicast Delegates Exist

Consider a notification delegate on a shopping cart. The first version is straightforward. When the cart total changes, the cart invokes an OnTotalChanged delegate that points to a method which updates the cart icon in the header.

That works until the product manager asks for one more thing. Now an analytics module also needs to know when the total changes. The naive fix is to add a second delegate field, then a third when the recommendation engine asks for the same hook, then a fourth when the loyalty tier service joins in. Every new subscriber means another field on Cart and another null check inside AddItem. The cart slowly turns into a junction box.

Multicast delegates solve this by letting one delegate variable hold many handlers at once. The cart still has a single OnTotalChanged field. Subscribers add themselves with += and remove themselves with -=. The cart's job ends at OnTotalChanged?.Invoke(Total), which fans the call out to everyone on the list.

That's the entire idea. The rest of this lesson is about the rules: how the list grows and shrinks, what order it runs in, what happens to return values and exceptions, and how to walk the list yourself when the default behavior isn't what you want.

This lesson assumes you already know how to declare a delegate type, instantiate one from a method, and invoke it.

Combining Handlers with +=

A multicast delegate is built by combining two delegate values of the same type into one. The combined delegate holds an internal invocation list of the handlers in the order they were added. Invoking the combined delegate runs every handler in that list.

The += operator is the everyday syntax for this. It also handles the null case cleanly: if the left side is null, the result is just the right side, so you don't need a separate first-assignment branch.

Three handlers, one invocation, three lines of output. The cart code (in this case the handler!(89.97m) line) didn't have to know that there were three subscribers. It just invoked the delegate.

Under the hood, += lowers to a call on System.Delegate.Combine. The static method takes two delegate instances of the same type and returns a new instance whose invocation list is the concatenation of the two. Delegates are immutable, so neither input is modified; you get a fresh delegate back. That's why += reassigns the variable rather than mutating the existing value.

You can spell that out explicitly when you want to. The two snippets below behave the same way.

The explicit form makes the cast and the immutability obvious, but nobody writes it that way in real code. Use +=.

If the left side starts as null, += still works because Delegate.Combine treats a null argument as an empty list. The result is the non-null side wrapped as a single-handler delegate. This is why handler += SomeMethod; works without ever initializing handler to anything other than its default null.

You can also combine two non-null delegates that each already hold multiple handlers. The result is the concatenation of both lists, in order.

The combined all holds four handlers, in the order Email, Sms, Push, Webhook. The two input delegates are still valid on their own; combining them produced a third delegate and left both untouched.

The Invocation List and Its Order

The invocation list is an ordered sequence of Delegate instances stored inside a multicast delegate. You can think of it as a small array of single-target delegates that the runtime walks one element at a time when you invoke the multicast.

Two rules govern that walk:

  1. The order is FIFO. Handlers run in the order they were added with +=.
  2. The walk is synchronous. The runtime calls handler 1, waits for it to return, then calls handler 2, and so on. There's no parallelism, no thread hopping, no scheduling. It's just a for loop over an internal array.

The FIFO order matters more than people expect. If three handlers update a shared dashboard, the last one wins visually, so the order of subscription determines what the user sees. If one handler logs a snapshot of cart state and another mutates it, the logger needs to be subscribed first to capture the pre-change state.

The numbers are there for clarity; the actual order is purely the subscription order.

A diagram makes the structure easier to hold in your head. The multicast delegate is a single object, and inside it sits the invocation list with one entry per handler.

The arrows from H1 to H2 to H3 are not method calls. They represent the order the runtime walks the list. Each handler runs in isolation; one does not call the next. When H1 returns, the runtime moves on to H2.

Because delegates are immutable, the invocation list inside any given delegate value never changes. When you do handler += NewMethod, the runtime builds a new delegate whose list is the old list with NewMethod appended, then assigns that new delegate back to the variable. Anyone who captured the previous value still sees the old list.

snapshot still references the original two-handler delegate. Appending C produced a fresh delegate that handler now points to; snapshot is unaffected. This immutability matters when an event raise is in progress and a handler tries to unsubscribe itself or another handler; the in-progress raise sees the list as it was when invocation started, not as it is after the modification.

Walking the invocation list is a for loop over an internal array, but every handler runs synchronously and adds its own work to the same thread. A multicast delegate with ten slow handlers takes the sum of their times. When the caller can't afford that, dispatch handlers onto a thread pool or use a different mechanism (like a message queue) for the slow ones.

Removing Handlers with -=

The mirror of += is -=. It removes a handler from the invocation list and gives you back a new delegate without it. If the result is empty (no handlers left), the operator returns null rather than an empty delegate.

Like +=, the -= operator is sugar over a static call. The underlying method is Delegate.Remove, and its rules have several edge cases.

The first rule is the obvious one: removing a handler that isn't in the list is a no-op. The result is a delegate identical to the input, and no exception is thrown. This is convenient for cleanup paths where you don't want to track which handlers are still subscribed.

The second rule is more subtle. Remove matches by structural equality, not by reference. Two delegate values are considered equal if they have the same target object and the same method. For static methods, the target is null, and equality is simply method equality. For instance methods, the target instance has to match too.

Three handlers were added (email.Send, sms.Send, email.Send), and one email.Send was removed. The output shows two remaining handlers: the first email.Send and the sms.Send between them. The third one (the second email.Send) was removed. That's the third rule worth remembering: when the same handler appears multiple times in the list, -= removes the last occurrence.

The lambda gotcha falls out of the equality rule. Two lambdas that look identical in source are two different delegate instances unless the compiler decides to cache them, which it sometimes does and sometimes doesn't. Subscribing with a lambda and trying to unsubscribe with another lambda usually fails silently.

The -= did nothing because the lambda on the right was a fresh delegate that wasn't in the list. The original lambda is still subscribed. To unsubscribe a lambda, you have to keep a reference to the delegate you added:

Now both sides are the same delegate instance, so equality holds and Remove finds it.

Return Values in a Multicast Delegate

Up to here every handler has returned void, which is the common case for notifications. But a delegate's signature can include a return type, and you're allowed to combine handlers of any delegate type, including ones that return values.

The question is: what does the multicast invocation return when there are five handlers each producing their own value? C# answers the question by punting on it. The runtime keeps only the return value of the last handler in the invocation list. Every earlier handler's return value is discarded.

This is the kind of behavior that doesn't bite you while everything works, then bites hard when someone subscribes a second handler and your code mysteriously starts returning the wrong number.

Three handlers ran. Each one took the original 100m as input (none of them saw the others' output) and computed its own value. The seasonal handler returned 90, the loyalty handler returned 95, the clearance handler returned 50. The caller sees only 50, because that's the last handler's return value.

Two important details here. The handlers don't compose; this isn't a pipeline. Each one received the original 100m argument, not the previous handler's output. The discarded return values from ApplySeasonalDiscount and ApplyLoyaltyDiscount are silently lost. If your design depended on combining them somehow (smallest, largest, sum, average), you have to write that logic yourself.

The same principle applies to out and ref parameters in a multicast delegate signature. Only the last handler's effect on an out parameter is observable through normal invocation, because each handler writes to its own slot and the last write wins.

The natural conclusion is that multicast delegates are designed for void-returning notifications. If your handlers genuinely need to return values, pick one of these instead:

  • Use a single-cast delegate (one subscriber). The signature stays the same; you just don't combine handlers.
  • Walk the invocation list yourself with GetInvocationList() and collect every return value. That's the next section.
  • Refactor so handlers report results through a different channel: write into a shared list, raise a follow-up event, or call back into the caller with the result.

The diagram makes the asymmetry obvious. Every handler is invoked, but only the last one's return value escapes. The rest evaporate.

Exceptions Stop the Chain

The other surprise in multicast invocation is what happens when a handler throws. The runtime does not catch exceptions for you. When a handler throws, the multicast invocation stops on the spot, and the exception propagates to the caller of the delegate. Any handlers later in the list are never invoked.

This is fine when there's only one subscriber. It becomes a real problem when there are several, because one buggy subscriber can silently disable every subscriber that happened to be registered after it.

UpdateIcon ran. LogAnalytics threw. RefreshRecommendations never executed, because the runtime abandoned the rest of the invocation list the moment the exception escaped a handler. The caller catches the exception, but it has no information about which handlers ran, which one threw, or which ones were skipped.

This is the standard behavior, and you can't change it for the built-in invocation path. If you want the rest of the handlers to run even when one throws, you have to walk the invocation list yourself and decide how to handle each exception. That's the next section.

A diagram helps. The X marks where invocation stopped, and the dashed arrow is the path the runtime would have taken if no exception had been thrown.

The takeaway: in production code that broadcasts events to many subscribers, never trust the default invocation to be all-or-nothing. Either keep your handler list tiny and trusted, or build your own dispatch loop that isolates each handler.

There's a related quirk in how this interacts with out parameters and return values. If a handler in the middle of the list throws, none of the handlers after it run, but the handlers that already ran still had their side effects (file writes, network calls, log entries, mutation of shared state). You can't roll those back automatically. The first handler's email was already sent before the second handler crashed.

Using GetInvocationList() to Walk Handlers Yourself

When the default invocation behavior isn't what you want (you need every return value, or you want exceptions in one handler to not affect the others), you can ask the delegate for its invocation list directly and walk it manually.

The method is Delegate.GetInvocationList(). It returns a Delegate[] where each element is a single-target delegate pointing at one handler. You then iterate over the array, cast each entry back to your delegate type, and invoke it however you like.

A common use case is collecting every return value. Here's a price-adjuster pipeline that does exactly that: it asks every subscriber for its proposed discount and picks the largest one.

Each handler ran exactly once, the function captured every return value as it went, and the caller picked the maximum. Plain sources(100m) would have run the same three handlers but kept only the clearance handler's 50m as the visible return value. Here we're doing the same walk the runtime would have done, and we're free to keep every result.

The same technique solves the exception problem. Iterate the list yourself, wrap each handler call in try/catch, and decide what to do when one throws.

Three handlers were subscribed. The middle one threw. With RaiseSafely, the icon and the recommendation handlers still ran, and the failure was collected and reported at the end. The caller learned that exactly one handler failed and which one. The other side effects went through.

This pattern shows up in the implementation of events in frameworks like ASP.NET Core's lifecycle hooks, where one slow or buggy subscriber must not be allowed to silence the others. The convention is to collect failures into an AggregateException and throw it at the end, after every handler has had a chance to run.

GetInvocationList() allocates a new Delegate[] every time. For a handful of subscribers and an event that fires infrequently, this is fine. For a hot event that fires thousands of times per second with a stable list of subscribers, caching the array (or using a different broadcast mechanism entirely) avoids the per-raise allocation.

You can also use GetInvocationList() for diagnostics: count handlers, log who's subscribed, detect duplicates. The returned array reflects the delegate's invocation list at the moment of the call, which is itself a snapshot because delegates are immutable. Any subsequent += or -= on the original variable produces a new delegate and doesn't change the array you already have.

Each entry in the array is a single-target delegate, and you can inspect its Target (the instance the method belongs to, or null for static methods) and Method (a MethodInfo for the method). That's how diagnostic tools and serializers introspect delegate state without invoking it.