Day 25/90: How a C# Event Leaked 720 MB

90 Day C# Challenge: Zero to Production

I did not think you could leak memory in C#. Then an internal dashboard climbed from 180 MB to 900 MB over two days, and a heap dump showed forty thousand chart-panel objects that should have been collected weeks ago. Every one was rooted by a single long-lived event. This is the post I wish I had read before that incident: delegates and events from the ground up, ending with the exact mistake that caused the leak and the discipline that closes it.

Delegates: typed references to methods

A delegate is a type whose values are methods. The built-in trio covers nearly everything:

Action notify = () => Console.WriteLine("changed"); // returns void Func<decimal, decimal> withTax = price => price * 1.2m; // returns a value Predicate<string> isBlank = string.IsNullOrWhiteSpace; // a Func<string,bool> notify(); Console.WriteLine(withTax(100m)); // 120.0 Console.WriteLine(isBlank("")); // True 

Each variable holds a method you can store, pass around, and call later. If you used LINQ yesterday, you already passed delegates everywhere -- Where takes a Func<T, bool>.

Multicast: the invocation list

A delegate can hold more than one method. += appends, -= removes, and invoking runs the whole list in order:

Action<int> chain = x => Console.WriteLine($"first saw {x}"); chain += x => Console.WriteLine($"second saw {x}"); chain(7); // both lines print Console.WriteLine(chain.GetInvocationList().Length); // 2 

The thing to hold onto: -= removes a handler only when you pass the same reference you added. A method group (obj.Method) gives you that stable reference. A fresh inline lambda does not -- so a handler you add as an inline lambda can never be removed.

The event keyword is access control

A raw public Action Changed; field lets any outside code raise the delegate and even assign null to wipe every subscriber. The event keyword restricts outsiders to += and -= only; just the declaring class can raise it. The framework's standard shape is EventHandler<TEventArgs> -- a sender plus a typed payload:

public sealed class PriceFeed { public event EventHandler<QuoteEventArgs>? Ticked; public void Push(string symbol, decimal price) => Ticked?.Invoke(this, new QuoteEventArgs(symbol, price)); } public sealed class QuoteEventArgs(string symbol, decimal price) : EventArgs { public string Symbol { get; } = symbol; public decimal Price { get; } = price; } 

The ?.Invoke guards the case where nobody has subscribed yet -- raising a null event throws otherwise.

The leak, in code

Here is the lifecycle that climbs the heap. The publisher's invocation list holds a strong reference to each subscriber, so a long-lived publisher pins every subscriber it ever saw:

public sealed class ChartPanel { public ChartPanel(PriceFeed feed) => feed.Ticked += OnTick; // feed now references this panel private void OnTick(object? sender, QuoteEventArgs e) => Console.WriteLine($"draw {e.Symbol} {e.Price}"); // no unsubscribe anywhere -> panel can never be collected } 

The panel feels like the owner -- it reaches out and subscribes -- but the reference points from feed back to the panel. While feed lives, the panel lives. A garbage collector reclaims what nothing references; an event is a reference.

The fix: name the handler, own the lifecycle

public sealed class ChartPanel : IDisposable { private readonly PriceFeed _feed; public ChartPanel(PriceFeed feed) { _feed = feed; _feed.Ticked += OnTick; // named method = stable reference } private void OnTick(object? sender, QuoteEventArgs e) => Console.WriteLine($"draw {e.Symbol} {e.Price}"); public void Dispose() => _feed.Ticked -= OnTick; // same reference, so it truly removes } 

Two moves make it safe. Subscribe with a named method so -= can hand back the identical reference. Implement IDisposable and unsubscribe in Dispose, then call Dispose on the close path so teardown is deterministic. After wiring that in, our heap flattened the same afternoon -- forty thousand leaked panels became zero.

When you cannot control teardown (a subscriber whose lifetime you do not own), the weak-event pattern lets the publisher hold subscribers weakly so they stay collectible. For code you own end to end, matched += / -= is simpler and easier to read.

Takeaways

  • A delegate is a typed method reference; Action/Func/Predicate cover most needs.
  • An event's publisher holds every subscriber in its invocation list -- a long-lived publisher roots short-lived subscribers.
  • -= needs the same reference, so subscribe with a named method, not an inline lambda.
  • Match every += with a -= via IDisposable, or use weak events when you cannot own teardown.

Tomorrow: generics, where Action<T> and Func<T> stop being magic and become something you can write yourself.