C# & .NET (C# 14, .NET 10 LTS) Module 12 – Delegates, lambdas, events and functional style
Events, subscriptions and memory leaks
Declare and raise C# events with the standard EventHandler<T> pattern, remove handlers so that no screen leaks, and raise events safely when handlers fail.
What you will learn
- Declare and raise an event with the standard pattern of EventHandler<T>, a sender and event data
- Remove a handler correctly, so that a long-lived publisher keeps no subscriber in memory
- Choose how a subscription ends, with Dispose, an IDisposable token or a weak handler
- Isolate failing handlers, and predict what a change during a raise or from many threads does
Before you start
What you’ll learn · Before you start
What you will learn
- Declare and raise an event with the standard pattern of EventHandler<T>, a sender and event data
- Remove a handler correctly, so that a long-lived publisher keeps no subscriber in memory
- Choose how a subscription ends, with Dispose, an IDisposable token or a weak handler
- Isolate failing handlers, and predict what a change during a raise or from many threads does
Before you start
On this page
An inventory must tell two parts of a shop when tea falls below 5 units: the reorder service orders more and the dashboard shows a warning. The inventory should know neither, and a third listener should need no change to it. Events do this, under one hard rule: a subscription lasts until somebody removes it.
You will declare and raise events with the standard .NET pattern, end subscriptions before they leak memory, and see why removing a lambda so often removes nothing.
An event lets other classes subscribe, and only its owner raises it
An event: A member, declared with the event keyword, that holds handlers of a delegate type. Code outside its class can only add and remove handlers with += and -=; only the declaring class can raise it. is a delegate field with a guard around it: outside its class it may only appear on
the left of += and -=, so other code can add and remove its own handlers but cannot replace everyone’s or raise the
event. outside tries both:
// Outside the class that declares it, an event may only appear on the left of += and -=. These two lines try to
// replace every subscriber and to raise the event from outside.
var inventory = new Inventory();
inventory.LowStock += (sender, sku) => Console.WriteLine($"reorder {sku}");
inventory.LowStock = (sender, sku) => Console.WriteLine($"only me: {sku}");
inventory.LowStock(null, "TEA-250");
class Inventory
{
public event EventHandler<string>? LowStock;
} from session import run
# outside assigns to an event with = and calls it, both from code outside the class that declares it. dotnet run
# builds the project first and stops at the errors.
run("""
cd outside
!dotnet run
""") Output
$ cd outside $ dotnet run outside/Program.cs(6,11): error CS0070: The event 'Inventory.LowStock' can only appear on the left hand side of += or -= (except when used from within the type 'Inventory') [outside/App.csproj] outside/Program.cs(7,11): error CS0070: The event 'Inventory.LowStock' can only appear on the left hand side of += or -= (except when used from within the type 'Inventory') [outside/App.csproj] The build failed. Fix the build errors and run again. [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 outside_session.py
Both lines failed with CS0070. On a public delegate field both would compile: one = would drop every other
subscriber, and any code could announce low stock that never happened.
.NET events follow one pattern, and inventory.cs uses all of it. The event’s type is EventHandler<TEventArgs>: its
handlers take the object that raised it, the sender, and the event’s data, and return nothing. The data is a record,
LowStockEventArgs, because TEventArgs no longer has to derive from EventArgs. One protected virtual method,
OnLowStock, raises the event with LowStock?.Invoke(this, e), which reads the delegate once and calls it only if
someone subscribed:
// The standard .NET event pattern: EventHandler<TEventArgs>, a sender and the event's data, raised from one protected
// virtual method with ?.Invoke. The stock numbers are made up for this lesson.
var inventory = new Inventory(threshold: 5);
var reorder = new ReorderService();
var dashboard = new Dashboard();
inventory.LowStock += reorder.OnLowStock;
inventory.LowStock += dashboard.OnLowStock;
inventory.Receive("TEA-250", 12);
inventory.Take("TEA-250", 4);
inventory.Take("TEA-250", 5);
inventory.LowStock -= dashboard.OnLowStock;
inventory.Take("TEA-250", 1);
Console.WriteLine($"orders placed by the reorder service: {reorder.Orders}");
// The event's data. TEventArgs no longer has to derive from EventArgs; a positional record keeps it read-only.
record LowStockEventArgs(string Sku, int UnitsLeft, int Threshold);
class Inventory(int threshold)
{
private readonly Dictionary<string, int> _units = [];
public event EventHandler<LowStockEventArgs>? LowStock;
public void Receive(string sku, int units) => _units[sku] = _units.GetValueOrDefault(sku) + units;
public void Take(string sku, int units)
{
int left = _units[sku] -= units;
Console.WriteLine($"took {units} of {sku}: {left} left");
if (left < threshold)
{
OnLowStock(new LowStockEventArgs(sku, left, threshold));
}
}
// The one place that raises the event. ?. reads LowStock once and calls it only when someone has subscribed.
protected virtual void OnLowStock(LowStockEventArgs e) => LowStock?.Invoke(this, e);
}
class ReorderService
{
public int Orders { get; private set; }
public void OnLowStock(object? sender, LowStockEventArgs e)
{
Orders++;
Console.WriteLine($" reorder service: {e.Sku} has {e.UnitsLeft}, order {Orders} placed");
}
}
class Dashboard
{
public void OnLowStock(object? sender, LowStockEventArgs e) =>
Console.WriteLine($" dashboard: {e.Sku} is below {e.Threshold}, reported by the {sender?.GetType().Name}");
} Output
took 4 of TEA-250: 8 left took 5 of TEA-250: 3 left reorder service: TEA-250 has 3, order 1 placed dashboard: TEA-250 is below 5, reported by the Inventory took 1 of TEA-250: 2 left reorder service: TEA-250 has 2, order 2 placed orders placed by the reorder service: 2
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run inventory.cs
Taking 4 units left 8, and nothing was raised; taking 5 more left 3, below the threshold, and both handlers ran in
the order they subscribed. After the dashboard’s -=, only the reorder service heard about the last unit: 2 orders
in all. Step through the raise at 3 units:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Stock falls below 5. Take("TEA-250", 5) leaves 3 units, below the threshold of 5, so the inventory calls its own OnLowStock with the event data: TEA-250, 3 left, threshold 5.
- One read of the event. OnLowStock runs LowStock?.Invoke(this, e). It reads the event's delegate once; two handlers are subscribed, so it calls them in the order they were added.
- The reorder service runs. The first handler, the reorder service's OnLowStock, gets the inventory as sender and the data, and places order 1.
- The dashboard runs. The second handler gets the same sender and the same data, and reports TEA-250 below 5, naming the Inventory as the sender.
- The raise is over. Both handlers have returned, and Take returns. The inventory never knew who listened: adding a third listener would need no change to it.
Microsoft’s guide to the pattern explains the void return: an event may have any number of listeners, none
included, and they give nothing back. That suits notifications inside one application. IObservable<T> also reports
when a stream ends or fails, IAsyncEnumerable<T> lets the consumer pull at its own pace, and a channel queues items
between producers and consumers.
Checkpoint
Check: Declaring and raising an event
A subscription keeps the subscriber alive until it is removed
A subscription: A handler added to an event with +=. It lasts until the same delegate is removed with -=, and while it lasts the publisher keeps the subscriber reachable, so it cannot be collected. is the handler that += stored in the publisher, and the handler’s
delegate refers to its target, the subscriber, so the publisher keeps the subscriber reachable while the handler stays.
Microsoft’s introduction to events calls this a coupling that the subscriber must end when it is no longer interested.
subscriptions.cs adds and removes handlers three ways:
// A subscription is a reference from the publisher to the subscriber's handler, and it lasts until the handler is
// removed with -=. The products are made up for this lesson.
var inventory = new Inventory();
// -= with a second lambda of the same text removes nothing: the two lambdas are two different delegates.
inventory.LowStock += (sender, sku) => Console.WriteLine($" refresh for {sku}");
inventory.LowStock -= (sender, sku) => Console.WriteLine($" refresh for {sku}");
Console.WriteLine($"after -= with a new lambda: {inventory.Handlers} handler");
// Keep the delegate that was added, and remove exactly that one.
EventHandler<string> badge = (sender, sku) => Console.WriteLine($" badge for {sku}");
inventory.LowStock += badge;
Console.WriteLine($"after += badge: {inventory.Handlers} handlers");
inventory.LowStock -= badge;
Console.WriteLine($"after -= badge: {inventory.Handlers} handler");
// A subscriber that adds its handler when it opens and removes it when it is disposed.
using (var screen = new StockScreen(inventory))
{
Console.WriteLine($"screen open: {inventory.Handlers} handlers; the last one runs on a {inventory.LastTarget}");
inventory.Raise("TEA-250");
}
Console.WriteLine($"screen disposed: {inventory.Handlers} handler");
inventory.Raise("RICE-5");
class Inventory
{
public event EventHandler<string>? LowStock;
public int Handlers => LowStock?.GetInvocationList().Length ?? 0;
public string LastTarget => LowStock?.GetInvocationList()[^1].Target?.GetType().Name ?? "none";
public void Raise(string sku)
{
Console.WriteLine($"{sku} is low:");
LowStock?.Invoke(this, sku);
}
}
sealed class StockScreen : IDisposable
{
private readonly Inventory _inventory;
public StockScreen(Inventory inventory)
{
_inventory = inventory;
_inventory.LowStock += OnLowStock;
}
// The method group OnLowStock makes an equal delegate each time, so -= finds and removes the one += added.
public void Dispose() => _inventory.LowStock -= OnLowStock;
private void OnLowStock(object? sender, string sku) => Console.WriteLine($" stock screen shows {sku}");
} Output
after -= with a new lambda: 1 handler after += badge: 2 handlers after -= badge: 1 handler screen open: 2 handlers; the last one runs on a StockScreen TEA-250 is low: refresh for TEA-250 stock screen shows TEA-250 screen disposed: 1 handler RICE-5 is low: refresh for RICE-5
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run subscriptions.cs
The first -= removed nothing: a new lambda with the same text is a different delegate, so the refresh handler
stayed and still ran for RICE-5. badge, kept in a variable, came off at once. StockScreen subscribes its method
OnLowStock when it opens and removes it in Dispose; the method group gives an equal delegate, so -= found it. Step through what the inventory holds:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The leftover lambda. The inventory's event already holds the refresh lambda: -= with a new lambda of the same text did not remove it, so 1 handler is left.
- The screen subscribes. new StockScreen(inventory) runs LowStock += OnLowStock. The event now holds a delegate whose target is the screen: 2 handlers.
- The event reaches both. Raise("TEA-250") calls the refresh lambda and then the screen's handler, in the order they were added.
- Dispose removes the handler. The using block ends and Dispose runs LowStock -= OnLowStock. The method group gives an equal delegate, so -= finds it: the inventory no longer refers to the screen.
- Only the leftover remains. Raise("RICE-5") reaches only the refresh lambda. Had the screen not unsubscribed, the inventory would have kept it alive and run its handler too.
This is how events cause memory leaks. A screen that subscribes to a long-lived inventory and never unsubscribes stays in memory after it closes, and keeps redrawing on every event; a static event is the worst case. Such memory leaks show no error, only memory that grows.
Removing a lambda with a copy of it
inventory.LowStock -= (sender, sku) => Refresh(sku); compiles, runs and removes nothing, because it builds a new
delegate that was never added. Keep the handler in a field or a variable, or subscribe a method, and remove that one.
There are three ways for subscriptions to end. Pair every += with a -= in the subscriber’s Dispose. Return the
subscription from a Subscribe method as an IDisposable, so that using ends it. Or keep the subscriber only through
a weak reference, so a forgotten one can still be collected.
Raising has its own cases. A handler that throws stops the handlers after it, unless the raise calls each handler of
GetInvocationList() in its own try. Handlers can also be removed, or added from other threads, while a raise runs.
Key idea
Subscriptions last until they are removed: an event holds every subscriber’s handler, and through it the subscriber, so remove exactly the delegate you added when the subscriber’s life ends.
Checkpoint
Check: How long a subscription keeps its subscriber
Ending subscriptions: a leak measured, IDisposable tokens and weak handlers Pro: opens with a Pro, Premium or Ultimate pass
A forgotten handler raises no error: the subscriber simply never goes away. The first run measures that on a static
event, and the next two end subscriptions in ways that do not depend on remembering a -=.
In this part:
- Three closed screens kept alive by a static event, measured
- A subscription as an IDisposable, which using ends
- Weak handlers, for publishers that cannot know their subscribers’ lives
3 runnable examples (C#) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Raising events safely: failing handlers, changes during a raise, threads and accessors Pro: opens with a Pro, Premium or Ultimate pass
Raising an event runs code that the publisher did not write. These runs show what one failing handler does to the others, what happens when the list changes during a raise, and how accessors control who may subscribe.
In this part:
- One failing handler stops the rest, and GetInvocationList isolates them
- Removing a handler during a raise, and subscribing from many threads
- async handlers, custom accessors and C# 14 partial events
- Events, observables, async streams or channels
3 runnable examples (C#) · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: a price alert that ends its own subscriptions
price_alert.cs checks a PriceAlert that raises ThresholdCrossed only when a price crosses its threshold, and
whose Subscribe returns an IDisposable. All nine checks fail on the starter:
// Practice: write PriceAlert, then run this file until all nine checks pass. What it must do is in the comments of
// the class. The products and prices are made up for this exercise.
(string Check, Func<string> Run, string Expected)[] checks =
[
("the first price only sets the start", () => Alerts(500, [480]), "none"),
("crossing upwards raises Up", () => Alerts(500, [480, 520]), "Up 520"),
("staying above raises nothing more", () => Alerts(500, [480, 520, 530, 560]), "Up 520"),
("crossing downwards raises Down", () => Alerts(500, [520, 495]), "Down 495"),
("reaching the threshold counts as above", () => Alerts(500, [480, 500, 499]), "Up 500, Down 499"),
("every subscriber hears a crossing", TwoSubscribers, "A: Up 510, B: Up 510"),
("disposing the subscription stops alerts", DisposeStops, "Up 520, then nothing"),
("disposing twice is harmless", DisposeTwice, "no error"),
("Subscribe refuses null", () => Outcome(() => { new PriceAlert("TEA-250", 500).Subscribe(null!); return "accepted"; }), "throws ArgumentNullException"),
];
int passed = 0;
foreach (var (check, run, expected) in checks)
{
string got = Outcome(run);
bool ok = got == expected;
if (ok)
{
passed++;
}
Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}
Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;
static string Alerts(decimal threshold, decimal[] prices)
{
var alert = new PriceAlert("TEA-250", threshold);
var seen = new List<string>();
alert.Subscribe((sender, e) => seen.Add($"{e.Direction} {e.Price}"));
foreach (decimal price in prices)
{
alert.Update(price);
}
return seen.Count == 0 ? "none" : string.Join(", ", seen);
}
static string TwoSubscribers()
{
var alert = new PriceAlert("TEA-250", 500);
var seen = new List<string>();
alert.Subscribe((sender, e) => seen.Add($"A: {e.Direction} {e.Price}"));
alert.Subscribe((sender, e) => seen.Add($"B: {e.Direction} {e.Price}"));
alert.Update(490);
alert.Update(510);
return string.Join(", ", seen);
}
static string DisposeStops()
{
var alert = new PriceAlert("TEA-250", 500);
var seen = new List<string>();
IDisposable subscription = alert.Subscribe((sender, e) => seen.Add($"{e.Direction} {e.Price}"));
alert.Update(480);
alert.Update(520);
subscription.Dispose();
alert.Update(470);
return $"{string.Join(", ", seen)}, then {(seen.Count == 1 ? "nothing" : "more")}";
}
static string DisposeTwice()
{
var alert = new PriceAlert("TEA-250", 500);
IDisposable subscription = alert.Subscribe((sender, e) => { });
subscription.Dispose();
subscription.Dispose();
return "no error";
}
static string Outcome(Func<string> run)
{
try
{
return run();
}
catch (Exception e)
{
return $"throws {e.GetType().Name}";
}
}
enum Direction
{
Up,
Down,
}
record ThresholdCrossedEventArgs(string Sku, decimal Price, Direction Direction);
class PriceAlert(string sku, decimal threshold)
{
// Raise this event when a new price crosses the threshold: from below it to at or above it (Up), or from at or
// above it to below it (Down). The first price only sets the starting side and raises nothing.
public event EventHandler<ThresholdCrossedEventArgs>? ThresholdCrossed;
public string Sku { get; } = sku;
public decimal Threshold { get; } = threshold;
// Records a new price and raises ThresholdCrossed when it crosses the threshold.
public void Update(decimal price) => throw new NotImplementedException();
// Adds the handler to ThresholdCrossed and returns an IDisposable whose Dispose removes it. Disposing again does
// nothing. A null handler throws ArgumentNullException.
public IDisposable Subscribe(EventHandler<ThresholdCrossedEventArgs> handler) => throw new NotImplementedException();
} Output (exit status 1)
the first price only sets the start: FAIL, throws NotImplementedException instead of none crossing upwards raises Up: FAIL, throws NotImplementedException instead of Up 520 staying above raises nothing more: FAIL, throws NotImplementedException instead of Up 520 crossing downwards raises Down: FAIL, throws NotImplementedException instead of Down 495 reaching the threshold counts as above: FAIL, throws NotImplementedException instead of Up 500, Down 499 every subscriber hears a crossing: FAIL, throws NotImplementedException instead of A: Up 510, B: Up 510 disposing the subscription stops alerts: FAIL, throws NotImplementedException instead of Up 520, then nothing disposing twice is harmless: FAIL, throws NotImplementedException instead of no error Subscribe refuses null: FAIL, throws NotImplementedException instead of throws ArgumentNullException 0 of 9 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run price_alert.cs
Write PriceAlert
Copy price_alert.cs. Keep the side of the last price in a bool? that starts null, and raise the event in
Update only when the new side differs from a known old one. In Subscribe, check the handler with
ArgumentNullException.ThrowIfNull, add it, and return an object whose Dispose removes it once. Run
dotnet run price_alert.cs until it prints 9 of 9 checks pass.
Then check yourself on declaring, raising and removing handlers:
Practice that adapts to you
Practise this lesson
6 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between a public delegate field and an event?
Both hold a delegate with any number of handlers; the difference is what outside code may do. A public delegate field
can be called by anyone, raising the notification, and assigned with =, which throws away every other subscriber.
An event lets outside code only add and remove its own handler with += and -=; raising and clearing stay with the
declaring class, and the compiler refuses the rest with CS0070. The class keeps control of when the notification
happens, and subscribers cannot disturb each other.
-
Mid to senior
A payment service raises PaymentCompleted with five subscribers. An analytics handler starts throwing during an outage, and from then on the audit handler, subscribed after it, records no payments, although the payments succeed. Explain why, and redesign the raise without losing the analytics errors.
-
Mid to senior
A background thread raises StockChanged, and a screen removes its handler in Dispose on the UI thread. Now and then the screen's handler runs after Dispose and throws ObjectDisposedException. The raise uses ?.Invoke. Explain why that does not prevent it, and fix it.
-
Senior to staff
A market-data service receives 20,000 price ticks a second and must feed a chart, a risk engine that sometimes pauses for half a second, and an archive that needs to know when the feed ends. Would you expose an event, IObservable<T>, IAsyncEnumerable<T> or a channel, and why?
Key takeaways
- An event is a delegate that only its class can raise: outside code can only add and remove handlers, and the
standard pattern is
EventHandler<TEventArgs>, raised once with?.Invoke(this, e). - An event keeps every subscriber’s handler, and through it the subscriber, until the handler is removed.
- Remove exactly the delegate you added: a method or a stored delegate, never a second lambda with the same text.
Terms in this lesson
- event
- A member, declared with the event keyword, that holds handlers of a delegate type. Code outside its class can only add and remove handlers with += and -=; only the declaring class can raise it.
- subscription
- A handler added to an event with +=. It lasts until the same delegate is removed with -=, and while it lasts the publisher keeps the subscriber reachable, so it cannot be collected.
References
- Introduction to events (Microsoft Learn)
- Standard .NET event patterns (Microsoft Learn)
- The updated .NET Core event pattern (Microsoft Learn)
- Delegates vs. events (Microsoft Learn)
- C# language specification: events (Microsoft Learn)
- C# language specification: delegate invocation (Microsoft Learn)
- Member access operators: thread-safe delegate invocation (Microsoft Learn)
- What's new in C# 14: more partial members (Microsoft Learn)
- Observer design pattern (Microsoft Learn)
- WeakReference<T> class (Microsoft Learn)
- Channels (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress